Compare commits

...

23 Commits

Author SHA1 Message Date
github-actions[bot]
73904e11a6 Update formbricks-js to 1.0.2 (#640)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2023-08-02 16:12:39 +02:00
Matti Nannt
a1b447caad Increase formbricks-js z-index to 999999 to increase compatibility with more websites (#639)
* Fix formbricks-js modal covered by other elements

* Fix wrong usage of prefix

* add changeset
2023-08-02 15:59:16 +02:00
Johannes
6b989487b2 Fix SEMRush SEO issues & move OSS friends to static
Fix SEMRush SEO issues & move OSS friends to static
2023-08-02 07:40:45 -05:00
Johannes
d60e0c4e5c fix SEO issues and move OSS friends to static 2023-08-02 14:24:26 +02:00
Shubham Palriwala
2a3ab3280f Fix NEXTAUTH_SECRET not get filled correctly in deployment script (#632)
* feat: handle openssl producing special characters that were causing errrs for sed to read

* feat: use all variables in dockerfile from the sole env itself
2023-08-02 13:33:29 +02:00
Matti Nannt
5b217e5483 Update pnpm-lock to solve build issues (#636) 2023-08-02 13:20:03 +02:00
tyjkerr
ec0d3f2fa2 Add Back Button to Surveys (#501)
* add back button, next with local storaage wip

* handle submission and skip submission logic

* handle showing stored value on same concurrent question type.

* remove console.log

* fix next button not showing, add saving answer on pressing back to local storage

* add temp props to QuestionCondition in preview modal

* add temp props to QuestionCondition in preview modal again...

* update navigation logic

* update survey question preview

* add back-button component

* add back button to formbricks/js

* refactor localStorage functions to lib

* remove unused import

* add form prefilling when reloading forms

* merge main into branch

* Revert "merge main into branch"

This reverts commit 13bc9c06ec.

* rename localStorage key answers->responses

* rename answers -> responses in linkSurvey lib

* when survey page reloaded jump to next question instead of current question

* rename getStoredAnswer -> getStoredResponse

* continue renaming

* continue renaming

* rename answerValue -> responseValue

---------

Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2023-08-02 13:08:20 +02:00
Johannes
ae702ddd06 Add Twenty.com to OSS friends
Add Twenty.com to OSS friends
2023-08-02 04:34:19 -05:00
Johannes
91f78d875b Add Twenty.com to OSS friends 2023-08-02 04:34:00 -05:00
Johannes
08110b0c34 LP: Add OSS friends via API and update pricing wording
LP: Add OSS friends via API and update pricing wording
2023-08-02 03:59:31 -05:00
Johannes
42e6601f13 update fetch URL 2023-08-02 10:47:26 +02:00
Johannes
a5c33981a0 update pricing wording, add OSS friends API 2023-08-02 10:29:06 +02:00
Johannes
1a90d1b7e8 Merge branch 'main' of github.com:formbricks/formbricks into lp/add-oss-friends 2023-08-02 09:57:03 +02:00
Johannes
3905c2227e Patch: Close survey on date can be set to past
Patch: Close survey on date can be set to past
2023-08-01 03:33:37 -05:00
Matthias Nannt
5ae7f31d01 update pnpm lock 2023-07-25 16:14:00 +02:00
Matthias Nannt
cb4cd706ad Merge branch 'main' of github.com:formbricks/formbricks into feat/close-date-edge-case 2023-07-25 16:00:05 +02:00
Johannes
2f8257ae62 added new members 2023-07-22 13:41:15 +02:00
Johannes
8a5217b39c OSS Api 2023-07-22 13:26:33 +02:00
Piyush Gupta
57e6c86e6a refactor: summary header 2023-07-22 11:02:59 +05:30
Piyush Gupta
4519cb8a2d Merge branch 'main' of https://github.com/gupta-piyush19/formbricks into feat/close-date-edge-case 2023-07-21 20:38:22 +05:30
Piyush Gupta
b20cda2d06 fix: removed today's date from closeOnDate date picker 2023-07-19 00:11:01 +05:30
Piyush Gupta
6e8be0c0bd fixed merge conflict 2023-07-18 19:40:01 +05:30
Piyush Gupta
c68a9c8d15 fix: edge case of close on date 2023-07-18 19:38:03 +05:30
56 changed files with 1430 additions and 643 deletions

View File

@@ -2,18 +2,18 @@ import { CodeFileIcon, EyeIcon, HandPuzzleIcon } from "@formbricks/ui";
import HeadingCentered from "../shared/HeadingCentered";
const features = [
{
id: "compliance",
name: "Smoothly Compliant",
description: "Use our GDPR-compliant Cloud or self-host the entire solution.",
icon: EyeIcon,
},
{
id: "customizable",
name: "Fully Customizable",
description: "Full customizability and extendability. Integrate with your stack easily.",
icon: HandPuzzleIcon,
},
{
id: "compliance",
name: "Smoothly Compliant",
description: "Self-host the entire product and fly through privacy compliance reviews.",
icon: EyeIcon,
},
{
id: "independent",
name: "Stay independent",
@@ -27,9 +27,9 @@ export const Features: React.FC = () => {
<div className="relative mx-auto max-w-7xl">
<HeadingCentered
closer
teaser="DATA Privacy at heart"
teaser="Data Privacy at heart"
heading="The only open-source solution"
subheading="Comply with all data privacy regulation with ease. Simply self-host."
subheading="Comply with all data privacy regulation with ease. Self-host if you want."
/>
<ul role="list" className="grid grid-cols-1 gap-4 pt-8 sm:grid-cols-2 md:grid-cols-3 lg:gap-10">

View File

@@ -9,7 +9,7 @@ export const GitHubSponsorship: React.FC = () => {
<style jsx>{`
@media (min-width: 426px);
`}</style>
<div className="right-10 lg:absolute">
<div className="right-24 lg:absolute">
<Image
src={GitHubMarkDark}
alt="GitHub Sponsors Formbricks badge"

View File

@@ -51,7 +51,7 @@ export default function Footer() {
<p className="text-base text-slate-500 dark:text-slate-400">Privacy-first Experience Management</p>
<div className="border-slate-500">
<p className="text-sm text-slate-400 dark:text-slate-500">
&copy; 2022. All rights reserved.
Formbricks GmbH &copy; 2022. All rights reserved.
<br />
<Link href="/imprint">Imprint</Link> | <Link href="/privacy">Privacy Policy</Link> |{" "}
<Link href="/terms">Terms</Link> | <Link href="/oss-friends">OSS Friends</Link>

View File

@@ -26,39 +26,39 @@ const tiers = [
href: "/docs/self-hosting/deployment",
},
{
name: "Free",
name: "Cloud",
href: "https://app.formbricks.com/auth/signup",
priceMonthly: "$0",
paymentRythm: "/month",
button: "highlight",
discounted: false,
highlight: true,
description: "All Pro features included.",
description: "Start with the 'Free forever' plan.",
features: [
"Unlimited surveys",
"Unlimited team members",
"Remove branding",
"Granular targeting",
"In-product surveys",
"Link surveys",
"Remove branding",
"Granular targeting",
"30+ templates",
"API access",
"Integrations (Slack, PostHog, Zapier)",
"Integrations (Zapier, Make, ...)",
"Unlimited team members",
"100 responses per survey",
],
ctaName: "Start for free",
ctaName: "Get started",
plausibleGoal: "Pricing_CTA_FreePlan",
},
{
name: "Pro",
name: "Cloud Pro",
href: "https://app.formbricks.com/auth/signup",
priceMonthly: "$99",
paymentRythm: "/month",
button: "secondary",
discounted: false,
highlight: false,
description: "All features included. Unlimited usage.",
features: ["Unlimited responses per survey"],
description: "All features, unlimited usage.",
features: ["Everything in 'Cloud'", "Unlimited responses per survey"],
ctaName: "Start for free",
plausibleGoal: "Pricing_CTA_ProPlan",
},
@@ -146,9 +146,12 @@ export default function Pricing() {
{tier.ctaName}
</Button>
{tier.name !== "Self-hosting" && (
{tier.name == "Cloud Pro" && (
<p className="mt-1.5 text-center text-xs text-slate-500">No Creditcard required.</p>
)}
{tier.name == "Cloud" && (
<p className="mt-1.5 text-center text-xs text-slate-500">Free forever 🤍</p>
)}
</div>
</div>
))}

View File

@@ -1,10 +1,134 @@
import { OSSFriends } from "@/pages/oss-friends";
import type { NextApiRequest, NextApiResponse } from "next";
export default async function handle(req: NextApiRequest, res: NextApiResponse) {
// GET
if (req.method === "GET") {
return res.status(200).json({ data: OSSFriends });
return res.status(200).json({
data: [
{
name: "Appsmith",
description: "Build build custom software on top of your data.",
href: "https://www.appsmith.com",
},
{
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:
"Cal.com is a scheduling tool that helps you schedule meetings without the back-and-forth emails.",
href: "https://cal.com",
},
{
name: "Crowd.dev",
description:
"Centralize community, product, and customer data to understand which companies are engaging with your open source project.",
href: "https://www.crowd.dev",
},
{
name: "Documenso",
description:
"The Open-Source DocuSign Alternative. We aim to earn your trust by enabling you to self-host the platform and examine its inner workings.",
href: "https://documenso.com",
},
{
name: "Erxes",
description:
"The Open-Source HubSpot Alternative. A single XOS enables to create unique and life-changing experiences that work for all types of business.",
href: "https://erxes.io",
},
{
name: "Formbricks",
description:
"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: "GitWonk",
description:
"GitWonk is an open-source technical documentation tool, designed and built focusing on the developer experience.",
href: "https://gitwonk.com",
},
{
name: "Hanko",
description:
"Open-source authentication and user management for the passkey era. Integrated in minutes, for web and mobile apps.",
href: "https://www.hanko.io",
},
{
name: "HTMX",
description:
"HTMX is a dependency-free JavaScript library that allows you to access AJAX, CSS Transitions, WebSockets, and Server Sent Events directly in HTML.",
href: "https://htmx.org",
},
{
name: "Infisical",
description:
"Open source, end-to-end encrypted platform that lets you securely manage secrets and configs across your team, devices, and infrastructure.",
href: "https://infisical.com",
},
{
name: "Mockoon",
description: "Mockoon is the easiest and quickest way to design and run mock REST APIs.",
href: "https://mockoon.com",
},
{
name: "Novu",
description:
"The open-source notification infrastructure for developers. Simple components and APIs for managing all communication channels in one place.",
href: "https://novu.co",
},
{
name: "OpenBB",
description:
"Democratizing investment research through an open source financial ecosystem. The OpenBB Terminal allows everyone to perform investment research, from everywhere.",
href: "https://openbb.co",
},
{
name: "Sniffnet",
description:
"Sniffnet is a network monitoring tool to help you easily keep track of your Internet traffic.",
href: "https://www.sniffnet.net",
},
{
name: "Tolgee",
description: "Software localization from A to Z made really easy.",
href: "https://tolgee.io/",
},
{
name: "Trigger.dev",
description:
"Create long-running Jobs directly in your codebase with features like API integrations, webhooks, scheduling and delays.",
href: "https://trigger.dev",
},
{
name: "Typebot",
description:
"Typebot gives you powerful blocks to create unique chat experiences. Embed them anywhere on your apps and start collecting results like magic.",
href: "https://typebot.io",
},
{
name: "Twenty",
description:
"A modern CRM offering the flexibility of open-source, advanced features and sleek design.",
href: "https://twenty.com",
},
{
name: "Webiny",
description:
"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",
},
],
});
}
// Unknown HTTP Method

View File

@@ -7,7 +7,7 @@ import BestPracticeNavigation from "@/components/shared/BestPracticeNavigation";
export default function DocsFeedbackPage() {
return (
<Layout
title="Feedback Box"
title="Docs Feedback"
description="The better your docs, the higher your user adoption. Measure granularly how clear your documentation is.">
<div className="grid grid-cols-1 items-center md:grid-cols-2 md:gap-12 md:py-20">
<div className="p-6 md:p-0">

View File

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

View File

@@ -3,7 +3,7 @@ import { Fence } from "@/components/shared/Fence";
export const meta = {
title: "Webhook Payload",
description: "Learn how to use the Formbricks Webhook API.",
description: "Learn how to handle the Formbricks API payload.",
};
This documentation helps understand the payload structure that will be received when the webhook is triggered in Formbricks.

View File

@@ -2,6 +2,7 @@ import Layout from "@/components/shared/LayoutMdx";
export const meta = {
title: "Imprint",
description: "Imprint of formbricks.com",
};
## Information according to § 5 TMG
@@ -17,19 +18,24 @@ E-Mail: hola@formbricks.com
## EU dispute resolution
The European Commission provides a platform for online dispute resolution (OS): https://ec.europa.eu/consumers/odr.\
You can find our e-mail address in the imprint above.\
Consumer dispute resolution/universal dispute resolution body\
The European Commission provides a platform for online dispute resolution (OS): https://ec.europa.eu/consumers/odr
You can also reach out via the e-mail address in the imprint above.
### Consumer dispute resolution/universal dispute resolution body
We are not willing or obliged to participate in dispute resolution proceedings before a consumer arbitration board.
## Liability for contents
As a service provider, we are responsible for our own content on these pages in accordance with § 7 paragraph 1 TMG under the general laws. According to §§ 8 to 10 TMG, we are not obligated to monitor transmitted or stored information or to investigate circumstances that indicate illegal activity.\
Obligations to remove or block the use of information under the general laws remain unaffected. However, liability in this regard is only possible from the point in time at which a concrete infringement of the law becomes known. If we become aware of any such infringements, we will remove the relevant content immediately.
## Liability for links
Our offer contains links to external websites of third parties, on whose contents we have no influence. Therefore, we cannot assume any liability for these external contents. The respective provider or operator of the sites is always responsible for the content of the linked sites. The linked pages were checked for possible legal violations at the time of linking. Illegal contents were not recognizable at the time of linking.\
However, a permanent control of the contents of the linked pages is not reasonable without concrete evidence of a violation of the law. If we become aware of any infringements, we will remove such links immediately.
## Copyright

View File

@@ -2,105 +2,17 @@ import Layout from "@/components/shared/Layout";
import HeroTitle from "@/components/shared/HeroTitle";
import { Button } from "@formbricks/ui";
export 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:
"Cal.com is a scheduling tool that helps you schedule meetings without the back-and-forth emails.",
href: "https://cal.com",
},
{
name: "Crowd.dev",
description:
"Centralize community, product, and customer data to understand which companies are engaging with your open source project.",
href: "https://www.crowd.dev",
},
{
name: "Documenso",
description:
"The Open-Source DocuSign Alternative. We aim to earn your trust by enabling you to self-host the platform and examine its inner workings.",
href: "https://documenso.com",
},
{
name: "Erxes",
description:
"The Open-Source HubSpot Alternative. A single XOS enables to create unique and life-changing experiences that work for all types of business.",
href: "https://erxes.io",
},
{
name: "Formbricks",
description:
"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: "GitWonk",
description:
"GitWonk is an open-source technical documentation tool, designed and built focusing on the developer experience.",
href: "https://gitwonk.com",
},
{
name: "Hanko",
description:
"Open-source authentication and user management for the passkey era. Integrated in minutes, for web and mobile apps.",
href: "https://www.hanko.io",
},
{
name: "HTMX",
description:
"HTMX is a dependency-free JavaScript library that allows you to access AJAX, CSS Transitions, WebSockets, and Server Sent Events directly in HTML.",
href: "https://htmx.org",
},
{
name: "Infisical",
description:
"Open source, end-to-end encrypted platform that lets you securely manage secrets and configs across your team, devices, and infrastructure.",
href: "https://infisical.com",
},
{
name: "Novu",
description:
"The open-source notification infrastructure for developers. Simple components and APIs for managing all communication channels in one place.",
href: "https://novu.co",
},
{
name: "OpenBB",
description:
"Democratizing investment research through an open source financial ecosystem. The OpenBB Terminal allows everyone to perform investment research, from everywhere.",
href: "https://openbb.co",
},
{
name: "Sniffnet",
description:
"Sniffnet is a network monitoring tool to help you easily keep track of your Internet traffic.",
href: "https://www.sniffnet.net",
},
{
name: "Typebot",
description:
"Typebot gives you powerful blocks to create unique chat experiences. Embed them anywhere on your apps and start collecting results like magic.",
href: "https://typebot.io",
},
{
name: "Webiny",
description:
"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",
},
];
type OSSFriend = {
href: string;
name: string;
description: string;
};
export default function OSSFriendsPage() {
type Props = {
OSSFriends: OSSFriend[];
};
export default function OSSFriendsPage({ OSSFriends }: Props) {
return (
<Layout title="OSS Friends" description="Open-source projects and tools for an open world.">
<HeroTitle headingPt1="Our" headingTeal="Open-source" headingPt2="Friends" />
@@ -122,3 +34,16 @@ export default function OSSFriendsPage() {
</Layout>
);
}
export async function getStaticProps() {
const res = await fetch("https://formbricks.com/api/oss-friends");
const data = await res.json();
// By returning { props: { OSSFriends } }, the OSSFriendsPage component
// will receive `OSSFriends` as a prop at build time
return {
props: {
OSSFriends: data.data,
},
};
}

View File

@@ -2,6 +2,7 @@ import Layout from "@/components/shared/LayoutMdx";
export const meta = {
title: "Privacy Policy",
description: "Formbricks Privacy Policy",
};
## **1. Introduction**

View File

@@ -3,6 +3,7 @@ import { Callout } from "@/components/shared/Callout";
export const meta = {
title: "Terms of Service",
description: "Terms of Service of Formbricks Cloud.",
};
These Terms of Use constitute a legally binding agreement made between you, whether personally or on behalf of an entity (“you”) and Formbricks ("**Company**", “**we**”, “**us**”, or “**our**”), concerning your access to and use of the https://formbricks.com website as well as any other media form, media channel, mobile website or mobile application related, linked, or otherwise connected thereto (collectively, the “Site”). You agree that by accessing the Site, you have read, understood, and agree to be bound by all of these Terms of Use. If you do not agree with all of these terms of use, then you are expressly prohibited from using the site and you must discontinue use immediately.

View File

@@ -1,5 +1,12 @@
# @formbricks/web
## 0.1.2
### Patch Changes
- Updated dependencies [a1b447ca]
- @formbricks/js@1.0.2
## 0.1.1
### Patch Changes

View File

@@ -41,6 +41,11 @@ export default function PreviewSurvey({
const [widgetSetupCompleted, setWidgetSetupCompleted] = useState(false);
const [lastActiveQuestionId, setLastActiveQuestionId] = useState("");
const [showFormbricksSignature, setShowFormbricksSignature] = useState(false);
const [finished, setFinished] = useState(false);
const [storedResponseValue, setStoredResponseValue] = useState<any>();
const [storedResponse, setStoredResponse] = useState<Record<string, any>>({});
const showBackButton = progress !== 0 && !finished;
useEffect(() => {
if (product) {
@@ -129,54 +134,54 @@ export default function PreviewSurvey({
}
}, [activeQuestionId, surveyType, questions, setActiveQuestionId, thankYouCard]);
function evaluateCondition(logic: Logic, answerValue: any): boolean {
function evaluateCondition(logic: Logic, responseValue: any): boolean {
switch (logic.condition) {
case "equals":
return (
(Array.isArray(answerValue) && answerValue.length === 1 && answerValue.includes(logic.value)) ||
answerValue.toString() === logic.value
(Array.isArray(responseValue) && responseValue.length === 1 && responseValue.includes(logic.value)) ||
responseValue.toString() === logic.value
);
case "notEquals":
return answerValue !== logic.value;
return responseValue !== logic.value;
case "lessThan":
return logic.value !== undefined && answerValue < logic.value;
return logic.value !== undefined && responseValue < logic.value;
case "lessEqual":
return logic.value !== undefined && answerValue <= logic.value;
return logic.value !== undefined && responseValue <= logic.value;
case "greaterThan":
return logic.value !== undefined && answerValue > logic.value;
return logic.value !== undefined && responseValue > logic.value;
case "greaterEqual":
return logic.value !== undefined && answerValue >= logic.value;
return logic.value !== undefined && responseValue >= logic.value;
case "includesAll":
return (
Array.isArray(answerValue) &&
Array.isArray(responseValue) &&
Array.isArray(logic.value) &&
logic.value.every((v) => answerValue.includes(v))
logic.value.every((v) => responseValue.includes(v))
);
case "includesOne":
return (
Array.isArray(answerValue) &&
Array.isArray(responseValue) &&
Array.isArray(logic.value) &&
logic.value.some((v) => answerValue.includes(v))
logic.value.some((v) => responseValue.includes(v))
);
case "accepted":
return answerValue === "accepted";
return responseValue === "accepted";
case "clicked":
return answerValue === "clicked";
return responseValue === "clicked";
case "submitted":
if (typeof answerValue === "string") {
return answerValue !== "dismissed" && answerValue !== "" && answerValue !== null;
} else if (Array.isArray(answerValue)) {
return answerValue.length > 0;
} else if (typeof answerValue === "number") {
return answerValue !== null;
if (typeof responseValue === "string") {
return responseValue !== "dismissed" && responseValue !== "" && responseValue !== null;
} else if (Array.isArray(responseValue)) {
return responseValue.length > 0;
} else if (typeof responseValue === "number") {
return responseValue !== null;
}
return false;
case "skipped":
return (
(Array.isArray(answerValue) && answerValue.length === 0) ||
answerValue === "" ||
answerValue === null ||
answerValue === "dismissed"
(Array.isArray(responseValue) && responseValue.length === 0) ||
responseValue === "" ||
responseValue === null ||
responseValue === "dismissed"
);
default:
return false;
@@ -191,14 +196,14 @@ export default function PreviewSurvey({
const currentQuestionIndex = questions.findIndex((q) => q.id === activeQuestionId);
if (currentQuestionIndex === -1) throw new Error("Question not found");
const answerValue = answer[activeQuestionId];
const responseValue = answer[activeQuestionId];
const currentQuestion = questions[currentQuestionIndex];
if (currentQuestion.logic && currentQuestion.logic.length > 0) {
for (let logic of currentQuestion.logic) {
if (!logic.destination) continue;
if (evaluateCondition(logic, answerValue)) {
if (evaluateCondition(logic, responseValue)) {
return logic.destination;
}
}
@@ -207,11 +212,13 @@ export default function PreviewSurvey({
}
const gotoNextQuestion = (data) => {
setStoredResponse({ ...storedResponse, ...data });
const nextQuestionId = getNextQuestion(data);
setStoredResponseValue(storedResponse[nextQuestionId]);
if (nextQuestionId !== "end") {
setActiveQuestionId(nextQuestionId);
} else {
setFinished(true);
if (thankYouCard?.enabled) {
setActiveQuestionId("thank-you-card");
setProgress(1);
@@ -225,6 +232,15 @@ export default function PreviewSurvey({
}
};
function goToPreviousQuestion(data: any) {
setStoredResponse({ ...storedResponse, ...data });
const currentQuestionIndex = questions.findIndex((q) => q.id === activeQuestionId);
if (currentQuestionIndex === -1) throw new Error("Question not found");
const previousQuestionId = questions[currentQuestionIndex - 1].id;
setStoredResponseValue(storedResponse[previousQuestionId]);
setActiveQuestionId(previousQuestionId);
}
useEffect(() => {
if (environment && environment.widgetSetupCompleted) {
setWidgetSetupCompleted(true);
@@ -280,6 +296,9 @@ export default function PreviewSurvey({
brandColor={brandColor}
lastQuestion={idx === questions.length - 1}
onSubmit={gotoNextQuestion}
storedResponseValue={storedResponseValue}
goToNextQuestion={gotoNextQuestion}
goToPreviousQuestion={showBackButton ? goToPreviousQuestion : undefined}
autoFocus={false}
/>
) : null
@@ -308,6 +327,9 @@ export default function PreviewSurvey({
brandColor={brandColor}
lastQuestion={idx === questions.length - 1}
onSubmit={gotoNextQuestion}
storedResponseValue={storedResponseValue}
goToNextQuestion={gotoNextQuestion}
goToPreviousQuestion={showBackButton ? goToPreviousQuestion : undefined}
autoFocus={false}
/>
) : null

View File

@@ -4,11 +4,6 @@ import { useEnvironment } from "@/lib/environments/environments";
import { useProduct } from "@/lib/products/products";
import { TSurvey } from "@formbricks/types/v1/surveys";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Button,
DropdownMenu,
DropdownMenuContent,
@@ -22,13 +17,7 @@ import {
DropdownMenuTrigger,
ErrorComponent,
} from "@formbricks/ui";
import {
CheckCircleIcon,
PauseCircleIcon,
PlayCircleIcon,
PencilSquareIcon,
EllipsisHorizontalIcon,
} from "@heroicons/react/24/solid";
import { PencilSquareIcon, EllipsisHorizontalIcon } from "@heroicons/react/24/solid";
import SurveyStatusIndicator from "@/components/shared/SurveyStatusIndicator";
import { useSurveyMutation } from "@/lib/surveys/mutateSurveys";
import toast from "react-hot-toast";
@@ -36,6 +25,7 @@ import { useRouter } from "next/navigation";
import SuccessMessage from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/SuccessMessage";
import LinkSurveyShareButton from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/LinkModalButton";
import LoadingSpinner from "@/components/shared/LoadingSpinner";
import SurveyStatusDropdown from "@/components/shared/SurveyStatusDropdown";
interface SummaryHeaderProps {
surveyId: string;
@@ -48,6 +38,10 @@ const SummaryHeader = ({ surveyId, environmentId, survey }: SummaryHeaderProps)
const { environment, isLoadingEnvironment, isErrorEnvironment } = useEnvironment(environmentId);
const { triggerSurveyMutate } = useSurveyMutation(environmentId, surveyId);
const isCloseOnDateEnabled = survey.closeOnDate !== null;
const closeOnDate = survey.closeOnDate ? new Date(survey.closeOnDate) : null;
const isStatusChangeDisabled = (isCloseOnDateEnabled && closeOnDate && closeOnDate < new Date()) ?? false;
if (isLoadingProduct || isLoadingEnvironment) {
return <LoadingSpinner />;
}
@@ -64,53 +58,7 @@ const SummaryHeader = ({ surveyId, environmentId, survey }: SummaryHeaderProps)
<div className="hidden justify-end gap-x-1.5 sm:flex">
{survey.type === "link" && <LinkSurveyShareButton survey={survey} />}
{(environment?.widgetSetupCompleted || survey.type === "link") && survey?.status !== "draft" ? (
<Select
onValueChange={(value) => {
triggerSurveyMutate({ status: value })
.then(() => {
toast.success(
value === "inProgress"
? "Survey live"
: value === "paused"
? "Survey paused"
: value === "completed"
? "Survey completed"
: ""
);
router.refresh();
})
.catch((error) => {
toast.error(`Error: ${error.message}`);
});
}}>
<SelectTrigger className="w-[170px] bg-white py-6 md:w-[200px]">
<SelectValue>
<div className="flex items-center">
<SurveyStatusIndicator status={survey.status} environmentId={environmentId} />
<span className="ml-2 text-sm text-slate-700">
{survey.status === "inProgress" && "In-progress"}
{survey.status === "paused" && "Paused"}
{survey.status === "completed" && "Completed"}
{survey.status === "archived" && "Archived"}
</span>
</div>
</SelectValue>
</SelectTrigger>
<SelectContent className="bg-white">
<SelectItem className="group font-normal hover:text-slate-900" value="inProgress">
<PlayCircleIcon className="-mt-1 mr-1 inline h-5 w-5 text-slate-500 group-hover:text-slate-800" />
In-progress
</SelectItem>
<SelectItem className="group font-normal hover:text-slate-900" value="paused">
<PauseCircleIcon className="-mt-1 mr-1 inline h-5 w-5 text-slate-500 group-hover:text-slate-800" />
Paused
</SelectItem>
<SelectItem className="group font-normal hover:text-slate-900" value="completed">
<CheckCircleIcon className="-mt-1 mr-1 inline h-5 w-5 text-slate-500 group-hover:text-slate-800" />
Completed
</SelectItem>
</SelectContent>
</Select>
<SurveyStatusDropdown environmentId={environmentId} surveyId={surveyId} />
) : null}
<Button
variant="darkCTA"
@@ -137,7 +85,9 @@ const SummaryHeader = ({ surveyId, environmentId, survey }: SummaryHeaderProps)
{(environment?.widgetSetupCompleted || survey.type === "link") && survey?.status !== "draft" ? (
<>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<DropdownMenuSubTrigger
disabled={isStatusChangeDisabled}
style={isStatusChangeDisabled ? { pointerEvents: "none", opacity: 0.5 } : {}}>
<div className="flex items-center">
<SurveyStatusIndicator status={survey.status} environmentId={environmentId} />
<span className="ml-1 text-sm text-slate-700">

View File

@@ -1,6 +1,12 @@
import { SigninForm } from "@/components/auth/SigninForm";
import Testimonial from "@/components/auth/Testimonial";
import FormWrapper from "@/components/auth/FormWrapper";
import { Metadata } from "next";
export const metadata: Metadata = {
title: "Login",
description: "Open-source Experience Management. Free & open source.",
};
export default function SignInPage() {
return (

View File

@@ -34,8 +34,12 @@ export default function LinkSurvey({ survey }: LinkSurveyProps) {
initiateCountdown,
restartSurvey,
submitResponse,
goToPreviousQuestion,
goToNextQuestion,
storedResponseValue,
} = useLinkSurveyUtils(survey);
const showBackButton = progress !== 0 && !finished;
// Create a reference to the top element
const topRef = useRef<HTMLDivElement>(null);
const [autoFocus, setAutofocus] = useState(false);
@@ -98,6 +102,9 @@ export default function LinkSurvey({ survey }: LinkSurveyProps) {
brandColor={survey.brandColor}
lastQuestion={lastQuestion}
onSubmit={submitResponse}
storedResponseValue={storedResponseValue}
goToNextQuestion={goToNextQuestion}
goToPreviousQuestion={showBackButton ? goToPreviousQuestion : undefined}
autoFocus={autoFocus}
/>
)}

View File

@@ -185,7 +185,7 @@ export const SignupForm = () => {
{env.NEXT_PUBLIC_TERMS_URL && (
<Link
className="font-semibold"
href="google.com" /* {env.NEXT_PUBLIC_TERMS_URL} */
href={env.NEXT_PUBLIC_TERMS_URL}
rel="noreferrer"
target="_blank">
Terms of Service
@@ -195,7 +195,7 @@ export const SignupForm = () => {
{env.NEXT_PUBLIC_PRIVACY_URL && (
<Link
className="font-semibold"
href="google.com" /* {/* env.NEXT_PUBLIC_PRIVACY_URL }*/
href={env.NEXT_PUBLIC_PRIVACY_URL}
rel="noreferrer"
target="_blank">
Privacy Policy.

View File

@@ -0,0 +1,17 @@
import { Button } from "@formbricks/ui";
interface BackButtonProps {
onClick: () => void;
}
export function BackButton({ onClick }: BackButtonProps) {
return (
<Button
type="button"
variant="minimal"
className="mr-auto px-3 py-3 text-base font-medium leading-4 focus:ring-offset-2"
onClick={() => onClick()}>
Back
</Button>
);
}

View File

@@ -3,30 +3,48 @@ import Headline from "./Headline";
import HtmlBody from "./HtmlBody";
import { cn } from "@/../../packages/lib/cn";
import { isLight } from "@/lib/utils";
import { Response } from "@formbricks/types/js";
import { BackButton } from "@/components/preview/BackButton";
interface CTAQuestionProps {
question: CTAQuestion;
onSubmit: (data: { [x: string]: any }) => void;
lastQuestion: boolean;
brandColor: string;
storedResponseValue: string | null;
goToNextQuestion: (answer: Response["data"]) => void;
goToPreviousQuestion?: (answer?: Response["data"]) => void;
}
export default function CTAQuestion({ question, onSubmit, lastQuestion, brandColor }: CTAQuestionProps) {
export default function CTAQuestion({
question,
onSubmit,
lastQuestion,
brandColor,
storedResponseValue,
goToNextQuestion,
goToPreviousQuestion,
}: CTAQuestionProps) {
return (
<div>
<Headline headline={question.headline} questionId={question.id} />
<HtmlBody htmlString={question.html || ""} questionId={question.id} />
<div className="mt-4 flex w-full justify-end">
{goToPreviousQuestion && <BackButton onClick={() => goToPreviousQuestion()} />}
<div></div>
{!question.required && (
{(!question.required || storedResponseValue) && (
<button
type="button"
onClick={() => {
if (storedResponseValue) {
goToNextQuestion({ [question.id]: "clicked" });
return;
}
onSubmit({ [question.id]: "dismissed" });
}}
className="mr-4 flex items-center rounded-md px-3 py-3 text-base font-medium leading-4 text-slate-500 hover:opacity-90 focus:outline-none focus:ring-2 focus:ring-slate-500 focus:ring-offset-2 dark:border-slate-400 dark:text-slate-400">
{question.dismissButtonLabel || "Skip"}
{storedResponseValue === "clicked" ? "Next" : question.dismissButtonLabel || "Skip"}
</button>
)}
<button

View File

@@ -1,14 +1,20 @@
import { cn } from "@/../../packages/lib/cn";
import { BackButton } from "@/components/preview/BackButton";
import { isLight } from "@/lib/utils";
import { Response } from "@formbricks/types/js";
import type { ConsentQuestion } from "@formbricks/types/questions";
import { useEffect, useState } from "react";
import Headline from "./Headline";
import HtmlBody from "./HtmlBody";
import { cn } from "@/../../packages/lib/cn";
import { isLight } from "@/lib/utils";
interface ConsentQuestionProps {
question: ConsentQuestion;
onSubmit: (data: { [x: string]: any }) => void;
lastQuestion: boolean;
brandColor: string;
storedResponseValue: string | null;
goToNextQuestion: (answer: Response["data"]) => void;
goToPreviousQuestion?: (answer?: Response["data"]) => void;
}
export default function ConsentQuestion({
@@ -16,18 +22,42 @@ export default function ConsentQuestion({
onSubmit,
lastQuestion,
brandColor,
storedResponseValue,
goToNextQuestion,
goToPreviousQuestion,
}: ConsentQuestionProps) {
const [answer, setAnswer] = useState<string>("dismissed");
useEffect(() => {
setAnswer(storedResponseValue ?? "dismissed");
}, [storedResponseValue, question]);
const handleOnChange = () => {
answer === "accepted" ? setAnswer("dissmissed") : setAnswer("accepted");
};
const handleSumbit = (value: string) => {
const data = {
[question.id]: value,
};
if (storedResponseValue === value) {
goToNextQuestion(data);
setAnswer("dismissed");
return;
}
onSubmit(data);
setAnswer("dismissed");
};
return (
<div>
<Headline headline={question.headline} questionId={question.id} />
<HtmlBody htmlString={question.html || ""} questionId={question.id} />
<form
onSubmit={(e) => {
e.preventDefault();
const checkbox = document.getElementById(question.id) as HTMLInputElement;
onSubmit({ [question.id]: checkbox.checked ? "accepted" : "dismissed" });
handleSumbit(answer);
}}>
<label className="relative z-10 mt-4 flex w-full cursor-pointer items-center rounded-md border border-gray-200 bg-slate-50 p-4 text-sm focus:outline-none">
<input
@@ -37,6 +67,8 @@ export default function ConsentQuestion({
value={question.label}
className="h-4 w-4 border border-slate-300 focus:ring-0 focus:ring-offset-0"
aria-labelledby={`${question.id}-label`}
onChange={handleOnChange}
checked={answer === "accepted"}
style={{ borderColor: brandColor, color: brandColor }}
required={question.required}
/>
@@ -45,7 +77,17 @@ export default function ConsentQuestion({
</span>
</label>
<div className="mt-4 flex w-full justify-end">
<div className="mt-4 flex w-full justify-between">
{goToPreviousQuestion && (
<BackButton
onClick={() =>
goToPreviousQuestion({
[question.id]: answer,
})
}
/>
)}
<div></div>
<button
type="submit"
className={cn(

View File

@@ -1,4 +1,4 @@
import { Input } from "@/../../packages/ui";
import { Input } from "@formbricks/ui";
import SubmitButton from "@/components/preview/SubmitButton";
import { shuffleArray } from "@/lib/utils";
import { cn } from "@formbricks/lib/cn";
@@ -6,12 +6,18 @@ import type { Choice, MultipleChoiceMultiQuestion } from "@formbricks/types/ques
import { useEffect, useState } from "react";
import Headline from "./Headline";
import Subheader from "./Subheader";
import _ from "lodash";
import { Response } from "@formbricks/types/js";
import { BackButton } from "@/components/preview/BackButton";
interface MultipleChoiceMultiProps {
question: MultipleChoiceMultiQuestion;
onSubmit: (data: { [x: string]: any }) => void;
lastQuestion: boolean;
brandColor: string;
storedResponseValue: string[] | null;
goToNextQuestion: (answer: Response["data"]) => void;
goToPreviousQuestion?: (answer: Response["data"]) => void;
}
export default function MultipleChoiceMultiQuestion({
@@ -19,11 +25,32 @@ export default function MultipleChoiceMultiQuestion({
onSubmit,
lastQuestion,
brandColor,
storedResponseValue,
goToNextQuestion,
goToPreviousQuestion,
}: MultipleChoiceMultiProps) {
const [selectedChoices, setSelectedChoices] = useState<string[]>([]);
const [isAtLeastOneChecked, setIsAtLeastOneChecked] = useState(false);
const [showOther, setShowOther] = useState(false);
const [otherSpecified, setOtherSpecified] = useState("");
const nonOtherChoiceLabels = question.choices
.filter((label) => label.id !== "other")
.map((choice) => choice.label);
useEffect(() => {
const nonOtherSavedChoices = storedResponseValue?.filter((answer) => nonOtherChoiceLabels.includes(answer));
const savedOtherSpecified = storedResponseValue?.find((answer) => !nonOtherChoiceLabels.includes(answer));
setSelectedChoices(nonOtherSavedChoices ?? []);
if (savedOtherSpecified) {
setOtherSpecified(savedOtherSpecified);
setShowOther(true);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [storedResponseValue, question.id]);
const [questionChoices, setQuestionChoices] = useState<Choice[]>(
question.choices
? question.shuffleOption !== "none"
@@ -31,12 +58,33 @@ export default function MultipleChoiceMultiQuestion({
: question.choices
: []
);
/* const [isIphone, setIsIphone] = useState(false);
*/
useEffect(() => {
setIsAtLeastOneChecked(selectedChoices.length > 0 || otherSpecified.length > 0);
}, [selectedChoices, otherSpecified]);
const resetForm = () => {
setSelectedChoices([]); // reset value
setShowOther(false);
setOtherSpecified("");
};
const handleSubmit = () => {
const data = {
[question.id]: selectedChoices,
};
if (_.xor(selectedChoices, storedResponseValue).length === 0) {
goToNextQuestion(data);
return;
}
if (question.required && selectedChoices.length <= 0) {
return;
}
onSubmit(data);
};
useEffect(() => {
setQuestionChoices(
question.choices
@@ -55,20 +103,8 @@ export default function MultipleChoiceMultiQuestion({
if (otherSpecified.length > 0 && showOther) {
selectedChoices.push(otherSpecified);
}
if (question.required && selectedChoices.length <= 0) {
return;
}
const data = {
[question.id]: selectedChoices,
};
onSubmit(data);
setSelectedChoices([]); // reset value
setShowOther(false);
setOtherSpecified("");
handleSubmit();
resetForm();
}}>
<Headline headline={question.headline} questionId={question.id} />
<Subheader subheader={question.subheader} questionId={question.id} />
@@ -77,7 +113,7 @@ export default function MultipleChoiceMultiQuestion({
<legend className="sr-only">Options</legend>
<div className="xs:max-h-[41vh] relative max-h-[60vh] space-y-2 overflow-y-auto rounded-md py-0.5 pr-2">
{questionChoices.map((choice) => (
<>
<div key={choice.id}>
<label
key={choice.id}
className={cn(
@@ -124,6 +160,7 @@ export default function MultipleChoiceMultiQuestion({
name={question.id}
className="mt-2 bg-white focus:border-slate-300"
placeholder="Please specify"
value={otherSpecified}
onChange={(e) => setOtherSpecified(e.currentTarget.value)}
aria-labelledby={`${choice.id}-label`}
required={question.required}
@@ -132,7 +169,7 @@ export default function MultipleChoiceMultiQuestion({
)}
</span>
</label>
</>
</div>
))}
</div>
</fieldset>
@@ -145,6 +182,19 @@ export default function MultipleChoiceMultiQuestion({
onChange={() => {}}
/>
<div className="mt-4 flex w-full justify-between">
{goToPreviousQuestion && (
<BackButton
onClick={() => {
if (otherSpecified.length > 0 && showOther) {
selectedChoices.push(otherSpecified);
}
goToPreviousQuestion({
[question.id]: selectedChoices,
});
resetForm();
}}
/>
)}
<div></div>
<SubmitButton {...{ question, lastQuestion, brandColor }} />
</div>

View File

@@ -1,18 +1,23 @@
import { Input } from "@/../../packages/ui";
import SubmitButton from "@/components/preview/SubmitButton";
import { shuffleArray } from "@/lib/utils";
import { cn } from "@formbricks/lib/cn";
import { Response } from "@formbricks/types/js";
import { MultipleChoiceSingleQuestion } from "@formbricks/types/questions";
import { TSurveyChoice } from "@formbricks/types/v1/surveys";
import { Input } from "@formbricks/ui";
import { useEffect, useRef, useState } from "react";
import Headline from "./Headline";
import Subheader from "./Subheader";
import { BackButton } from "@/components/preview/BackButton";
interface MultipleChoiceSingleProps {
question: MultipleChoiceSingleQuestion;
onSubmit: (data: { [x: string]: any }) => void;
lastQuestion: boolean;
brandColor: string;
storedResponseValue: string | null;
goToNextQuestion: (answer: Response["data"]) => void;
goToPreviousQuestion?: (answer?: Response["data"]) => void;
}
export default function MultipleChoiceSingleQuestion({
@@ -20,8 +25,13 @@ export default function MultipleChoiceSingleQuestion({
onSubmit,
lastQuestion,
brandColor,
storedResponseValue,
goToNextQuestion,
goToPreviousQuestion,
}: MultipleChoiceSingleProps) {
const storedResponseValueValue = question.choices.find((choice) => choice.label === storedResponseValue)?.id;
const [selectedChoice, setSelectedChoice] = useState<string | null>(null);
const [savedOtherAnswer, setSavedOtherAnswer] = useState<string | null>(null);
const [questionChoices, setQuestionChoices] = useState<TSurveyChoice[]>(
question.choices
? question.shuffleOption && question.shuffleOption !== "none"
@@ -32,10 +42,41 @@ export default function MultipleChoiceSingleQuestion({
const otherSpecify = useRef<HTMLInputElement>(null);
useEffect(() => {
if (selectedChoice === "other") {
otherSpecify.current?.focus();
if (!storedResponseValueValue) {
const otherChoiceId = question.choices.find((choice) => choice.id === "other")?.id;
if (otherChoiceId && storedResponseValue) {
setSelectedChoice(otherChoiceId);
setSavedOtherAnswer(storedResponseValue);
}
} else {
setSelectedChoice(storedResponseValueValue);
}
}, [selectedChoice]);
}, [question.choices, storedResponseValue, storedResponseValueValue]);
useEffect(() => {
if (selectedChoice === "other" && otherSpecify.current) {
otherSpecify.current.value = savedOtherAnswer ?? "";
otherSpecify.current.focus();
}
}, [savedOtherAnswer, selectedChoice]);
const resetForm = () => {
setSelectedChoice(null);
setSavedOtherAnswer(null);
};
const handleSubmit = (value: string) => {
const data = {
[question.id]: value,
};
if (value === storedResponseValue) {
goToNextQuestion(data);
resetForm(); // reset form
return;
}
onSubmit(data);
resetForm(); // reset form
};
useEffect(() => {
setQuestionChoices(
@@ -52,11 +93,7 @@ export default function MultipleChoiceSingleQuestion({
onSubmit={(e) => {
e.preventDefault();
const value = otherSpecify.current?.value || e.currentTarget[question.id].value;
const data = {
[question.id]: value,
};
onSubmit(data);
setSelectedChoice(null); // reset form
handleSubmit(value);
}}>
<Headline headline={question.headline} questionId={question.id} />
<Subheader subheader={question.subheader} questionId={question.id} />
@@ -108,6 +145,22 @@ export default function MultipleChoiceSingleQuestion({
</fieldset>
</div>
<div className="mt-4 flex w-full justify-between">
{goToPreviousQuestion && (
<BackButton
onClick={() => {
goToPreviousQuestion(
selectedChoice === "other"
? {
[question.id]: otherSpecify.current?.value ?? "",
}
: {
[question.id]:
question.choices.find((choice) => choice.id === selectedChoice)?.label ?? "",
}
);
}}
/>
)}
<div></div>
<SubmitButton {...{ question, lastQuestion, brandColor }} />
</div>

View File

@@ -1,27 +1,54 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import { cn } from "@formbricks/lib/cn";
import type { NPSQuestion } from "@formbricks/types/questions";
import Headline from "./Headline";
import Subheader from "./Subheader";
import SubmitButton from "@/components/preview/SubmitButton";
import { Response } from "@formbricks/types/js";
import { BackButton } from "@/components/preview/BackButton";
interface NPSQuestionProps {
question: NPSQuestion;
onSubmit: (data: { [x: string]: any }) => void;
lastQuestion: boolean;
brandColor: string;
storedResponseValue: number | null;
goToNextQuestion: (answer: Response["data"]) => void;
goToPreviousQuestion?: (answer?: Response["data"]) => void;
}
export default function NPSQuestion({ question, onSubmit, lastQuestion, brandColor }: NPSQuestionProps) {
export default function NPSQuestion({
question,
onSubmit,
lastQuestion,
brandColor,
storedResponseValue,
goToNextQuestion,
goToPreviousQuestion,
}: NPSQuestionProps) {
const [selectedChoice, setSelectedChoice] = useState<number | null>(null);
useEffect(() => {
setSelectedChoice(storedResponseValue);
}, [storedResponseValue, question]);
const handleSubmit = (value: number | null) => {
const data = {
[question.id]: value ?? null,
};
if (storedResponseValue === value) {
setSelectedChoice(null);
goToNextQuestion(data);
return;
}
setSelectedChoice(null);
onSubmit(data);
};
const handleSelect = (number: number) => {
setSelectedChoice(number);
if (question.required) {
setSelectedChoice(null);
onSubmit({
[question.id]: number,
});
handleSubmit(number);
}
};
@@ -29,14 +56,7 @@ export default function NPSQuestion({ question, onSubmit, lastQuestion, brandCol
<form
onSubmit={(e) => {
e.preventDefault();
const data = {
[question.id]: selectedChoice,
};
setSelectedChoice(null);
onSubmit(data);
// reset form
handleSubmit(selectedChoice);
}}>
<Headline headline={question.headline} questionId={question.id} />
<Subheader subheader={question.subheader} questionId={question.id} />
@@ -55,6 +75,7 @@ export default function NPSQuestion({ question, onSubmit, lastQuestion, brandCol
type="radio"
name="nps"
value={number}
checked={selectedChoice === number}
className="absolute h-full w-full cursor-pointer opacity-0"
onClick={() => handleSelect(number)}
required={question.required}
@@ -69,12 +90,23 @@ export default function NPSQuestion({ question, onSubmit, lastQuestion, brandCol
</div>
</fieldset>
</div>
{!question.required && (
<div className="mt-4 flex w-full justify-between">
<div></div>
<SubmitButton {...{ question, lastQuestion, brandColor }} />
</div>
)}
<div className="mt-4 flex w-full justify-between">
{goToPreviousQuestion && (
<BackButton
onClick={() => {
goToPreviousQuestion(
storedResponseValue !== selectedChoice
? {
[question.id]: selectedChoice,
}
: undefined
);
}}
/>
)}
<div></div>
{(!question.required || storedResponseValue) && <SubmitButton {...{ question, lastQuestion, brandColor }} />}
</div>
</form>
);
}

View File

@@ -1,14 +1,19 @@
import type { OpenTextQuestion } from "@formbricks/types/questions";
import { useState } from "react";
import { useEffect, useState } from "react";
import Headline from "./Headline";
import Subheader from "./Subheader";
import SubmitButton from "@/components/preview/SubmitButton";
import { Response } from "@formbricks/types/js";
import { BackButton } from "@/components/preview/BackButton";
interface OpenTextQuestionProps {
question: OpenTextQuestion;
onSubmit: (data: { [x: string]: any }) => void;
lastQuestion: boolean;
brandColor: string;
storedResponseValue: string | null;
goToNextQuestion: (answer: Response["data"]) => void;
goToPreviousQuestion?: (answer: Response["data"]) => void;
autoFocus?: boolean;
}
@@ -17,52 +22,75 @@ export default function OpenTextQuestion({
onSubmit,
lastQuestion,
brandColor,
storedResponseValue,
goToNextQuestion,
goToPreviousQuestion,
autoFocus = false,
}: OpenTextQuestionProps) {
const [value, setValue] = useState<string>("");
useEffect(() => {
setValue(storedResponseValue ?? "");
}, [storedResponseValue, question.id, question.longAnswer]);
const handleSubmit = (value: string) => {
const data = {
[question.id]: value,
};
if (storedResponseValue === value) {
goToNextQuestion(data);
return;
}
onSubmit(data);
setValue(""); // reset value
};
return (
<form
onSubmit={(e) => {
e.preventDefault();
const data = {
[question.id]: value,
};
setValue(""); // reset value
onSubmit(data);
handleSubmit(value);
}}>
<Headline headline={question.headline} questionId={question.id} />
<Subheader subheader={question.subheader} questionId={question.id} />
<div className="mt-4">
{question.longAnswer === false ? (
<input
autoFocus={autoFocus}
autoFocus={autoFocus && !storedResponseValue}
name={question.id}
id={question.id}
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder={question.placeholder}
placeholder={!storedResponseValue ? question.placeholder : undefined}
required={question.required}
className="block w-full rounded-md border border-slate-100 bg-slate-50 p-2 shadow-sm focus:border-slate-500 focus:outline-none focus:ring-0 sm:text-sm"
/>
) : (
<textarea
autoFocus={autoFocus}
autoFocus={autoFocus && !storedResponseValue}
rows={3}
name={question.id}
id={question.id}
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder={question.placeholder}
placeholder={!storedResponseValue ? question.placeholder : undefined}
required={question.required}
className="block w-full rounded-md border border-slate-100 bg-slate-50 p-2 shadow-sm focus:border-slate-500 focus:ring-0 sm:text-sm"
/>
)}
</div>
<div className="mt-4 flex w-full justify-between">
{goToPreviousQuestion && (
<BackButton
onClick={() => {
goToPreviousQuestion({
[question.id]: value,
});
}}
/>
)}
<div></div>
<SubmitButton {...{ question, lastQuestion, brandColor }} />
<SubmitButton {...{ question, lastQuestion, brandColor, storedResponseValue, goToNextQuestion }} />
</div>
</form>
);

View File

@@ -12,6 +12,9 @@ interface QuestionConditionalProps {
onSubmit: (data: { [x: string]: any }) => void;
lastQuestion: boolean;
brandColor: string;
storedResponseValue: any;
goToNextQuestion: (answer: any) => void;
goToPreviousQuestion?: (answer: any) => void;
autoFocus: boolean;
}
@@ -20,6 +23,9 @@ export default function QuestionConditional({
onSubmit,
lastQuestion,
brandColor,
storedResponseValue,
goToNextQuestion,
goToPreviousQuestion,
autoFocus,
}: QuestionConditionalProps) {
return question.type === QuestionType.OpenText ? (
@@ -28,6 +34,9 @@ export default function QuestionConditional({
onSubmit={onSubmit}
lastQuestion={lastQuestion}
brandColor={brandColor}
storedResponseValue={storedResponseValue}
goToNextQuestion={goToNextQuestion}
goToPreviousQuestion={goToPreviousQuestion}
autoFocus={autoFocus}
/>
) : question.type === QuestionType.MultipleChoiceSingle ? (
@@ -36,6 +45,9 @@ export default function QuestionConditional({
onSubmit={onSubmit}
lastQuestion={lastQuestion}
brandColor={brandColor}
storedResponseValue={storedResponseValue}
goToNextQuestion={goToNextQuestion}
goToPreviousQuestion={goToPreviousQuestion}
/>
) : question.type === QuestionType.MultipleChoiceMulti ? (
<MultipleChoiceMultiQuestion
@@ -43,6 +55,9 @@ export default function QuestionConditional({
onSubmit={onSubmit}
lastQuestion={lastQuestion}
brandColor={brandColor}
storedResponseValue={storedResponseValue}
goToNextQuestion={goToNextQuestion}
goToPreviousQuestion={goToPreviousQuestion}
/>
) : question.type === QuestionType.NPS ? (
<NPSQuestion
@@ -50,6 +65,9 @@ export default function QuestionConditional({
onSubmit={onSubmit}
lastQuestion={lastQuestion}
brandColor={brandColor}
storedResponseValue={storedResponseValue}
goToNextQuestion={goToNextQuestion}
goToPreviousQuestion={goToPreviousQuestion}
/>
) : question.type === QuestionType.CTA ? (
<CTAQuestion
@@ -57,6 +75,9 @@ export default function QuestionConditional({
onSubmit={onSubmit}
lastQuestion={lastQuestion}
brandColor={brandColor}
storedResponseValue={storedResponseValue}
goToNextQuestion={goToNextQuestion}
goToPreviousQuestion={goToPreviousQuestion}
/>
) : question.type === QuestionType.Rating ? (
<RatingQuestion
@@ -64,6 +85,9 @@ export default function QuestionConditional({
onSubmit={onSubmit}
lastQuestion={lastQuestion}
brandColor={brandColor}
storedResponseValue={storedResponseValue}
goToNextQuestion={goToNextQuestion}
goToPreviousQuestion={goToPreviousQuestion}
/>
) : question.type === "consent" ? (
<ConsentQuestion
@@ -71,6 +95,9 @@ export default function QuestionConditional({
onSubmit={onSubmit}
lastQuestion={lastQuestion}
brandColor={brandColor}
storedResponseValue={storedResponseValue}
goToNextQuestion={goToNextQuestion}
goToPreviousQuestion={goToPreviousQuestion}
/>
) : null;
}

View File

@@ -1,4 +1,4 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import { cn } from "@formbricks/lib/cn";
import type { RatingQuestion } from "@formbricks/types/questions";
import Headline from "./Headline";
@@ -19,11 +19,17 @@ import {
} from "../Smileys";
import SubmitButton from "@/components/preview/SubmitButton";
import { Response } from "@formbricks/types/js";
import { BackButton } from "@/components/preview/BackButton";
interface RatingQuestionProps {
question: RatingQuestion;
onSubmit: (data: { [x: string]: any }) => void;
lastQuestion: boolean;
brandColor: string;
storedResponseValue: number | null;
goToNextQuestion: (answer: Response["data"]) => void;
goToPreviousQuestion?: (answer?: Response["data"]) => void;
}
export default function RatingQuestion({
@@ -31,18 +37,35 @@ export default function RatingQuestion({
onSubmit,
lastQuestion,
brandColor,
storedResponseValue,
goToNextQuestion,
goToPreviousQuestion,
}: RatingQuestionProps) {
const [selectedChoice, setSelectedChoice] = useState<number | null>(null);
const [hoveredNumber, setHoveredNumber] = useState(0);
// const icons = RatingSmileyList(question.range);
useEffect(() => {
setSelectedChoice(storedResponseValue);
}, [storedResponseValue, question]);
const handleSubmit = (value: number | null) => {
const data = {
[question.id]: value ?? null,
};
if (storedResponseValue === value) {
goToNextQuestion(data);
setSelectedChoice(null);
return;
}
onSubmit(data);
setSelectedChoice(null);
};
const handleSelect = (number: number) => {
setSelectedChoice(number);
if (question.required) {
onSubmit({
[question.id]: number,
});
setSelectedChoice(null); // reset choice
handleSubmit(number);
}
};
@@ -53,6 +76,7 @@ export default function RatingQuestion({
value={number}
className="absolute left-0 h-full w-full cursor-pointer opacity-0"
onChange={() => handleSelect(number)}
checked={selectedChoice === number}
required={question.required}
/>
);
@@ -61,14 +85,7 @@ export default function RatingQuestion({
<form
onSubmit={(e) => {
e.preventDefault();
const data = {
[question.id]: selectedChoice,
};
setSelectedChoice(null); // reset choice
onSubmit(data);
handleSubmit(selectedChoice);
}}>
<Headline headline={question.headline} questionId={question.id} />
<Subheader subheader={question.subheader} questionId={question.id} />
@@ -128,12 +145,17 @@ export default function RatingQuestion({
</fieldset>
</div>
{!question.required && (
<div className="mt-4 flex w-full justify-between">
<div></div>
<SubmitButton {...{ question, lastQuestion, brandColor }} />
</div>
)}
<div className="mt-4 flex w-full justify-between">
{goToPreviousQuestion && (
<BackButton
onClick={() => {
goToPreviousQuestion({ [question.id]: selectedChoice });
}}
/>
)}
<div></div>
{(!question.required || storedResponseValue) && <SubmitButton {...{ question, lastQuestion, brandColor }} />}
</div>
</form>
);
}

View File

@@ -1,7 +1,14 @@
import { cn } from "@/../../packages/lib/cn";
import { isLight } from "@/lib/utils";
import { Question } from "@formbricks/types/questions";
function SubmitButton({ question, lastQuestion, brandColor }) {
type SubmitButtonProps = {
question: Question;
lastQuestion: boolean;
brandColor: string;
};
function SubmitButton({ question, lastQuestion, brandColor }: SubmitButtonProps) {
return (
<button
type="submit"

View File

@@ -11,6 +11,10 @@ import {
SelectItem,
SelectTrigger,
SelectValue,
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@formbricks/ui";
import { CheckCircleIcon, PauseCircleIcon, PlayCircleIcon } from "@heroicons/react/24/solid";
import toast from "react-hot-toast";
@@ -35,6 +39,10 @@ export default function SurveyStatusDropdown({
return <ErrorComponent />;
}
const isCloseOnDateEnabled = survey.closeOnDate !== null;
const closeOnDate = survey.closeOnDate ? new Date(survey.closeOnDate) : null;
const isStatusChangeDisabled = (isCloseOnDateEnabled && closeOnDate && closeOnDate < new Date()) ?? false;
return (
<>
{survey.status === "draft" || survey.status === "archived" ? (
@@ -44,56 +52,69 @@ export default function SurveyStatusDropdown({
{survey.status === "archived" && <p className="text-sm italic text-slate-600">Archived</p>}
</div>
) : (
<Select
onValueChange={(value) => {
triggerSurveyMutate({ status: value })
.then(() => {
toast.success(
value === "inProgress"
? "Survey live"
: value === "paused"
? "Survey paused"
: value === "completed"
? "Survey completed"
: ""
);
})
.catch((error) => {
toast.error(`Error: ${error.message}`);
});
<TooltipProvider delayDuration={50}>
<Tooltip open={isStatusChangeDisabled ? undefined : false}>
<TooltipTrigger>
<Select
disabled={isStatusChangeDisabled}
onValueChange={(value) => {
triggerSurveyMutate({ status: value })
.then(() => {
toast.success(
value === "inProgress"
? "Survey live"
: value === "paused"
? "Survey paused"
: value === "completed"
? "Survey completed"
: ""
);
})
.catch((error) => {
toast.error(`Error: ${error.message}`);
});
if (updateLocalSurveyStatus)
updateLocalSurveyStatus(value as "draft" | "inProgress" | "paused" | "completed" | "archived");
}}>
<SelectTrigger className="w-[170px] bg-white py-6 md:w-[200px]">
<SelectValue>
<div className="flex items-center">
<SurveyStatusIndicator status={survey.status} environmentId={environmentId} />
<span className="ml-2 text-sm text-slate-700">
{survey.status === "draft" && "Survey draft"}
{survey.status === "inProgress" && "Collecting insights"}
{survey.status === "paused" && "Survey paused"}
{survey.status === "completed" && "Survey complete"}
{survey.status === "archived" && "Survey archived"}
</span>
</div>
</SelectValue>
</SelectTrigger>
<SelectContent className="bg-white">
<SelectItem className="group font-normal hover:text-slate-900" value="inProgress">
<PlayCircleIcon className="-mt-1 mr-1 inline h-5 w-5 text-slate-500 group-hover:text-slate-800" />
Collect insights
</SelectItem>
<SelectItem className="group font-normal hover:text-slate-900" value="paused">
<PauseCircleIcon className="-mt-1 mr-1 inline h-5 w-5 text-slate-500 group-hover:text-slate-800" />
Pause Survey
</SelectItem>
<SelectItem className="group font-normal hover:text-slate-900" value="completed">
<CheckCircleIcon className="-mt-1 mr-1 inline h-5 w-5 text-slate-500 group-hover:text-slate-800" />
Complete Survey
</SelectItem>
</SelectContent>
</Select>
if (updateLocalSurveyStatus)
updateLocalSurveyStatus(
value as "draft" | "inProgress" | "paused" | "completed" | "archived"
);
}}>
<SelectTrigger className="w-[170px] bg-white py-6 md:w-[200px]">
<SelectValue>
<div className="flex items-center">
<SurveyStatusIndicator status={survey.status} environmentId={environmentId} />
<span className="ml-2 text-sm text-slate-700">
{survey.status === "draft" && "Draft"}
{survey.status === "inProgress" && "In-progress"}
{survey.status === "paused" && "Paused"}
{survey.status === "completed" && "Completed"}
{survey.status === "archived" && "Archived"}
</span>
</div>
</SelectValue>
</SelectTrigger>
<SelectContent className="bg-white">
<SelectItem className="group font-normal hover:text-slate-900" value="inProgress">
<PlayCircleIcon className="-mt-1 mr-1 inline h-5 w-5 text-slate-500 group-hover:text-slate-800" />
In-progress
</SelectItem>
<SelectItem className="group font-normal hover:text-slate-900" value="paused">
<PauseCircleIcon className="-mt-1 mr-1 inline h-5 w-5 text-slate-500 group-hover:text-slate-800" />
Paused
</SelectItem>
<SelectItem className="group font-normal hover:text-slate-900" value="completed">
<CheckCircleIcon className="-mt-1 mr-1 inline h-5 w-5 text-slate-500 group-hover:text-slate-800" />
Completed
</SelectItem>
</SelectContent>
</Select>
</TooltipTrigger>
<TooltipContent>
To update the survey status, update the &ldquo;Close
<br /> survey on date&rdquo; setting in the Response Options.
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</>
);

View File

@@ -8,6 +8,7 @@ import { useState, useEffect, useCallback } from "react";
import type { Survey } from "@formbricks/types/surveys";
import { useRouter } from "next/navigation";
import { useGetOrCreatePerson } from "../people/people";
import { Response } from "@formbricks/types/js";
export const useLinkSurvey = (surveyId: string) => {
const { data, error, mutate, isLoading } = useSWR(`/api/v1/client/surveys/${surveyId}`, fetcher);
@@ -29,6 +30,7 @@ export const useLinkSurveyUtils = (survey: Survey) => {
const [responseId, setResponseId] = useState<string | null>(null);
const [displayId, setDisplayId] = useState<string | null>(null);
const [initiateCountdown, setinitiateCountdown] = useState<boolean>(false);
const [storedResponseValue, setStoredResponseValue] = useState<string | null>(null);
const router = useRouter();
const URLParams = new URLSearchParams(window.location.search);
const isPreview = URLParams.get("preview") === "true";
@@ -41,9 +43,32 @@ export const useLinkSurveyUtils = (survey: Survey) => {
const { person, isLoadingPerson } = useGetOrCreatePerson(survey.environmentId, isPreview ? null : userId);
const personId = person?.data.person.id ?? null;
useEffect(() => {
const storedResponses = getStoredResponses(survey.id);
const questionKeys = survey.questions.map((question) => question.id);
if (storedResponses) {
const storedResponsesKeys = Object.keys(storedResponses);
// reduce to find the last answered question index
const lastAnsweredQuestionIndex = questionKeys.reduce((acc, key, index) => {
if (storedResponsesKeys.includes(key)) {
return index;
}
return acc;
}, 0);
if (lastAnsweredQuestionIndex > 0 && survey.questions.length > lastAnsweredQuestionIndex + 1) {
const nextQuestion = survey.questions[lastAnsweredQuestionIndex + 1];
setCurrentQuestion(nextQuestion);
setProgress(calculateProgress(nextQuestion, survey));
setStoredResponseValue(getStoredResponseValue(survey.id, nextQuestion.id));
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (!isLoadingPerson) {
if (survey) {
const storedResponses = getStoredResponses(survey.id);
if (survey && !storedResponses) {
setCurrentQuestion(survey.questions[0]);
if (isPreview) return;
@@ -70,22 +95,10 @@ export const useLinkSurveyUtils = (survey: Survey) => {
return elementIdx / survey.questions.length;
}, []);
const getNextQuestionId = (answer: any): string => {
const activeQuestionId: string = currentQuestion?.id || "";
const getNextQuestionId = (): string => {
const currentQuestionIndex = survey.questions.findIndex((q) => q.id === currentQuestion?.id);
if (currentQuestionIndex === -1) throw new Error("Question not found");
const answerValue = answer[activeQuestionId];
if (currentQuestion?.logic && currentQuestion?.logic.length > 0) {
for (let logic of currentQuestion.logic) {
if (!logic.destination) continue;
if (evaluateCondition(logic, answerValue)) {
return logic.destination;
}
}
}
if (lastQuestion) return "end";
return survey.questions[currentQuestionIndex + 1].id;
};
@@ -98,8 +111,19 @@ export const useLinkSurveyUtils = (survey: Survey) => {
const submitResponse = async (data: { [x: string]: any }) => {
setLoadingElement(true);
const activeQuestionId: string = currentQuestion?.id || "";
const nextQuestionId = getNextQuestionId();
const responseValue = data[activeQuestionId];
const nextQuestionId = getNextQuestionId(data);
if (currentQuestion?.logic && currentQuestion?.logic.length > 0) {
for (let logic of currentQuestion.logic) {
if (!logic.destination) continue;
if (evaluateCondition(logic, responseValue)) {
return logic.destination;
}
}
}
const finished = nextQuestionId === "end";
// build response
@@ -112,6 +136,7 @@ export const useLinkSurveyUtils = (survey: Survey) => {
url: window.location.href,
},
};
if (!responseId && !isPreview) {
const response = await createResponse(
responseRequest,
@@ -121,12 +146,14 @@ export const useLinkSurveyUtils = (survey: Survey) => {
markDisplayResponded(displayId, `${window.location.protocol}//${window.location.host}`);
}
setResponseId(response.id);
storeResponse(survey.id, response.data);
} else if (responseId && !isPreview) {
await updateResponse(
responseRequest,
responseId,
`${window.location.protocol}//${window.location.host}`
);
storeResponse(survey.id, data);
}
setLoadingElement(false);
@@ -136,11 +163,12 @@ export const useLinkSurveyUtils = (survey: Survey) => {
if (!question) throw new Error("Question not found");
setStoredResponseValue(getStoredResponseValue(survey.id, nextQuestionId));
setCurrentQuestion(question);
// setCurrentQuestion(survey.questions[questionIdx + 1]);
} else {
setProgress(1);
setFinished(true);
clearStoredResponses(survey.id);
if (survey.redirectUrl && Object.values(data)[0] !== "dismissed") {
handleRedirect(survey.redirectUrl);
}
@@ -183,6 +211,49 @@ export const useLinkSurveyUtils = (survey: Survey) => {
handlePrefilling();
}, [handlePrefilling]);
const getPreviousQuestionId = (): string => {
const currentQuestionIndex = survey.questions.findIndex((q) => q.id === currentQuestion?.id);
if (currentQuestionIndex === -1) throw new Error("Question not found");
return survey.questions[currentQuestionIndex - 1].id;
};
const goToPreviousQuestion = (answer: Response["data"]) => {
setLoadingElement(true);
const previousQuestionId = getPreviousQuestionId();
const previousQuestion = survey.questions.find((q) => q.id === previousQuestionId);
if (!previousQuestion) throw new Error("Question not found");
if (answer) {
storeResponse(survey.id, answer);
}
setStoredResponseValue(getStoredResponseValue(survey.id, previousQuestion.id));
setCurrentQuestion(previousQuestion);
setLoadingElement(false);
};
const goToNextQuestion = (answer: Response["data"]) => {
setLoadingElement(true);
const nextQuestionId = getNextQuestionId();
if (nextQuestionId === "end") {
submitResponse(answer);
return;
}
storeResponse(survey.id, answer);
const nextQuestion = survey.questions.find((q) => q.id === nextQuestionId);
if (!nextQuestion) throw new Error("Question not found");
setStoredResponseValue(getStoredResponseValue(survey.id, nextQuestion.id));
setCurrentQuestion(nextQuestion);
setLoadingElement(false);
};
return {
currentQuestion,
progress,
@@ -194,9 +265,43 @@ export const useLinkSurveyUtils = (survey: Survey) => {
initiateCountdown,
submitResponse,
restartSurvey,
goToPreviousQuestion,
goToNextQuestion,
storedResponseValue,
};
};
const storeResponse = (surveyId: string, answer: Response["data"]) => {
const storedResponses = localStorage.getItem(`formbricks-${surveyId}-responses`);
if (storedResponses) {
const parsedResponses = JSON.parse(storedResponses);
localStorage.setItem(`formbricks-${surveyId}-responses`, JSON.stringify({ ...parsedResponses, ...answer }));
} else {
localStorage.setItem(`formbricks-${surveyId}-responses`, JSON.stringify(answer));
}
};
const getStoredResponses = (surveyId: string): Record<string, string> | null => {
const storedResponses = localStorage.getItem(`formbricks-${surveyId}-responses`);
if (storedResponses) {
const parsedResponses = JSON.parse(storedResponses);
return parsedResponses;
}
return null;
};
const getStoredResponseValue = (surveyId: string, questionId: string): string | null => {
const storedResponses = getStoredResponses(surveyId);
if (storedResponses) {
return storedResponses[questionId];
}
return null;
};
const clearStoredResponses = (surveyId: string) => {
localStorage.removeItem(`formbricks-${surveyId}-responses`);
};
const checkValidity = (question: Question, answer: any): boolean => {
if (question.required && (!answer || answer === "")) return false;
try {
@@ -287,54 +392,54 @@ const createAnswer = (question: Question, answer: string): string | number | str
}
};
const evaluateCondition = (logic: Logic, answerValue: any): boolean => {
const evaluateCondition = (logic: Logic, responseValue: any): boolean => {
switch (logic.condition) {
case "equals":
return (
(Array.isArray(answerValue) && answerValue.length === 1 && answerValue.includes(logic.value)) ||
answerValue.toString() === logic.value
(Array.isArray(responseValue) && responseValue.length === 1 && responseValue.includes(logic.value)) ||
responseValue.toString() === logic.value
);
case "notEquals":
return answerValue !== logic.value;
return responseValue !== logic.value;
case "lessThan":
return logic.value !== undefined && answerValue < logic.value;
return logic.value !== undefined && responseValue < logic.value;
case "lessEqual":
return logic.value !== undefined && answerValue <= logic.value;
return logic.value !== undefined && responseValue <= logic.value;
case "greaterThan":
return logic.value !== undefined && answerValue > logic.value;
return logic.value !== undefined && responseValue > logic.value;
case "greaterEqual":
return logic.value !== undefined && answerValue >= logic.value;
return logic.value !== undefined && responseValue >= logic.value;
case "includesAll":
return (
Array.isArray(answerValue) &&
Array.isArray(responseValue) &&
Array.isArray(logic.value) &&
logic.value.every((v) => answerValue.includes(v))
logic.value.every((v) => responseValue.includes(v))
);
case "includesOne":
return (
Array.isArray(answerValue) &&
Array.isArray(responseValue) &&
Array.isArray(logic.value) &&
logic.value.some((v) => answerValue.includes(v))
logic.value.some((v) => responseValue.includes(v))
);
case "accepted":
return answerValue === "accepted";
return responseValue === "accepted";
case "clicked":
return answerValue === "clicked";
return responseValue === "clicked";
case "submitted":
if (typeof answerValue === "string") {
return answerValue !== "dismissed" && answerValue !== "" && answerValue !== null;
} else if (Array.isArray(answerValue)) {
return answerValue.length > 0;
} else if (typeof answerValue === "number") {
return answerValue !== null;
if (typeof responseValue === "string") {
return responseValue !== "dismissed" && responseValue !== "" && responseValue !== null;
} else if (Array.isArray(responseValue)) {
return responseValue.length > 0;
} else if (typeof responseValue === "number") {
return responseValue !== null;
}
return false;
case "skipped":
return (
(Array.isArray(answerValue) && answerValue.length === 0) ||
answerValue === "" ||
answerValue === null ||
answerValue === "dismissed"
(Array.isArray(responseValue) && responseValue.length === 0) ||
responseValue === "" ||
responseValue === null ||
responseValue === "dismissed"
);
default:
return false;

View File

@@ -1,6 +1,6 @@
{
"name": "@formbricks/web",
"version": "0.1.1",
"version": "0.1.2",
"private": true,
"scripts": {
"clean": "rimraf .turbo node_modules .next",

View File

@@ -19,14 +19,16 @@ x-environment: &environment
# You do not need the NEXTAUTH_URL environment variable in Vercel.
NEXTAUTH_URL: http://localhost:3000
# PostgreSQL password
POSTGRES_PASSWORD: postgres
services:
postgres:
restart: always
image: postgres:15-alpine
volumes:
- postgres:/var/lib/postgresql/data
environment:
- POSTGRES_PASSWORD=postgres
<<: *environment
formbricks:
restart: always

View File

@@ -126,14 +126,16 @@ x-environment: &environment
# You do not need the NEXTAUTH_URL environment variable in Vercel.
NEXTAUTH_URL: "https://$domain_name"
# PostgreSQL password
POSTGRES_PASSWORD: postgres
services:
postgres:
restart: always
image: postgres:15-alpine
volumes:
- postgres:/var/lib/postgresql/data
environment:
- POSTGRES_PASSWORD=postgres
<<: *environment
formbricks:
restart: always
@@ -167,20 +169,9 @@ volumes:
driver: local
EOT
update_nextauth_secret() {
nextauth_secret=$(openssl rand -base64 32)
sed -i "/NEXTAUTH_SECRET:$/s/NEXTAUTH_SECRET:.\*/NEXTAUTH_SECRET: $nextauth_secret/" docker-compose.yml
}
echo "🚙 Updating NEXTAUTH_SECRET in the Formbricks container..."
while true; do
if update_nextauth_secret; then
echo "🚗 NEXTAUTH_SECRET updated successfully!"
break
else
echo "🚧 Failed to update NEXTAUTH_SECRET. Retrying..."
fi
done
nextauth_secret=$(openssl rand -base64 32 | tr -dc 'a-zA-Z0-9' | head -c 32) && sed -i "/NEXTAUTH_SECRET:$/s/NEXTAUTH_SECRET:.*/NEXTAUTH_SECRET: $nextauth_secret/" docker-compose.yml
echo "🚗 NEXTAUTH_SECRET updated successfully!"
newgrp docker << END

View File

@@ -1,5 +1,11 @@
# @formbricks/js
## 1.0.2
### Patch Changes
- a1b447ca: Increase z-index to 999999 to increase compatibility with more websites
## 1.0.1
### Patch Changes

View File

@@ -1,7 +1,7 @@
{
"name": "@formbricks/js",
"license": "MIT",
"version": "1.0.1",
"version": "1.0.2",
"description": "Formbricks-js allows you to connect your app to Formbricks, display surveys and trigger events.",
"keywords": [
"Formbricks",

View File

@@ -5,6 +5,7 @@ import { useState } from "preact/hooks";
import Modal from "./components/Modal";
import SurveyView from "./components/SurveyView";
import { IErrorHandler } from "./lib/errors";
import { clearStoredResponse } from "./lib/localStorage";
interface AppProps {
config: TJsConfig;
@@ -18,6 +19,7 @@ export default function App({ config, survey, closeSurvey, errorHandler }: AppPr
const close = () => {
setIsOpen(false);
clearStoredResponse(survey.id);
setTimeout(() => {
closeSurvey();
}, 1000); // wait for animation to finish}

View File

@@ -0,0 +1,20 @@
import { h } from "preact";
import { cn } from "@/../../packages/lib/cn";
interface BackButtonProps {
onClick: () => void;
}
export function BackButton({ onClick }: BackButtonProps) {
return (
<button
type={"button"}
className={cn(
"fb-flex fb-items-center fb-rounded-md fb-border fb-border-transparent fb-px-3 fb-py-3 fb-text-base fb-font-medium fb-leading-4 fb-shadow-sm hover:fb-opacity-90 focus:fb-outline-none focus:fb-ring-2 focus:fb-ring-slate-500 focus:fb-ring-offset-2"
)}
onClick={onClick}>
Back
</button>
);
}

View File

@@ -4,56 +4,64 @@ import type { TSurveyCTAQuestion } from "../../../types/v1/surveys";
import Headline from "./Headline";
import HtmlBody from "./HtmlBody";
import SubmitButton from "./SubmitButton";
import { BackButton } from "./BackButton";
interface CTAQuestionProps {
question: TSurveyCTAQuestion;
onSubmit: (data: TResponseData) => void;
lastQuestion: boolean;
brandColor: string;
storedResponseValue: number | null;
goToNextQuestion: (answer: TResponseData) => void;
goToPreviousQuestion?: (answer?: TResponseData) => void;
}
export default function CTAQuestion({ question, onSubmit, lastQuestion, brandColor }: CTAQuestionProps) {
export default function CTAQuestion({
question,
onSubmit,
lastQuestion,
brandColor,
storedResponseValue,
goToNextQuestion,
goToPreviousQuestion,
}: CTAQuestionProps) {
return (
<div>
<Headline headline={question.headline} questionId={question.id} />
<HtmlBody htmlString={question.html} questionId={question.id} />
<div className="fb-mt-4 fb-flex fb-w-full fb-justify-end">
<div></div>
{!question.required && (
<button
type="button"
<div className="fb-mt-4 fb-flex fb-w-full fb-justify-between">
{goToPreviousQuestion && <BackButton onClick={() => goToPreviousQuestion()} />}
<div className="fb-flex fb-justify-end">
{(!question.required || storedResponseValue) && (
<button
type="button"
onClick={() => {
if (storedResponseValue) {
goToNextQuestion({ [question.id]: "clicked" });
return;
}
onSubmit({ [question.id]: "dismissed" });
}}
className="fb-flex fb-items-center dark:fb-text-slate-400 fb-rounded-md fb-px-3 fb-py-3 fb-text-base fb-font-medium fb-leading-4 fb-hover:opacity-90 fb-focus:outline-none fb-focus:ring-2 fb-focus:ring-slate-500 fb-focus:ring-offset-2 fb-mr-4">
{typeof storedResponseValue === "string" && storedResponseValue === "clicked"
? "Next"
: question.dismissButtonLabel || "Skip"}
</button>
)}
<SubmitButton
question={question}
lastQuestion={lastQuestion}
brandColor={brandColor}
onClick={() => {
onSubmit({ [question.id]: "dismissed" });
if (question.buttonExternal && question.buttonUrl) {
window?.open(question.buttonUrl, "_blank")?.focus();
}
onSubmit({ [question.id]: "clicked" });
}}
className="fb-flex fb-items-center dark:fb-text-slate-400 fb-rounded-md fb-px-3 fb-py-3 fb-text-base fb-font-medium fb-leading-4 fb-hover:opacity-90 fb-focus:outline-none fb-focus:ring-2 fb-focus:ring-slate-500 fb-focus:ring-offset-2 fb-mr-4">
{question.dismissButtonLabel || "Skip"}
</button>
)}
{/* <button
type="button"
onClick={() => {
if (question.buttonExternal && question.buttonUrl) {
window?.open(question.buttonUrl, "_blank")?.focus();
}
onSubmit({ [question.id]: "clicked" });
}}
className="fb-flex fb-items-center fb-rounded-md fb-border fb-border-transparent fb-px-3 fb-py-3 fb-text-base fb-font-medium fb-leading-4 fb-text-white fb-shadow-sm fb-hover:opacity-90 fb-focus:outline-none fb-focus:ring-2 fb-focus:ring-slate-500 fb-focus:ring-offset-2"
style={{ backgroundColor: brandColor }}>
{question.buttonLabel || (lastQuestion ? "Finish" : "Next")}
</button> */}
<SubmitButton
question={question}
lastQuestion={lastQuestion}
brandColor={brandColor}
onClick={() => {
if (question.buttonExternal && question.buttonUrl) {
window?.open(question.buttonUrl, "_blank")?.focus();
}
onSubmit({ [question.id]: "clicked" });
}}
type="button"
/>
type="button"
/>
</div>
</div>
</div>
);

View File

@@ -4,12 +4,17 @@ import type { TSurveyConsentQuestion } from "../../../types/v1/surveys";
import Headline from "./Headline";
import HtmlBody from "./HtmlBody";
import SubmitButton from "./SubmitButton";
import { useEffect, useState } from "preact/hooks";
import { BackButton } from "./BackButton";
interface ConsentQuestionProps {
question: TSurveyConsentQuestion;
onSubmit: (data: TResponseData) => void;
lastQuestion: boolean;
brandColor: string;
storedResponseValue: string | null;
goToNextQuestion: (answer: TResponseData) => void;
goToPreviousQuestion?: (answer?: TResponseData) => void;
}
export default function ConsentQuestion({
@@ -17,7 +22,33 @@ export default function ConsentQuestion({
onSubmit,
lastQuestion,
brandColor,
storedResponseValue,
goToNextQuestion,
goToPreviousQuestion,
}: ConsentQuestionProps) {
const [answer, setAnswer] = useState<string>("dismissed");
useEffect(() => {
setAnswer(storedResponseValue ?? "dismissed");
}, [storedResponseValue, question]);
const handleOnChange = () => {
answer === "accepted" ? setAnswer("dissmissed") : setAnswer("accepted");
};
const handleSumbit = (value: string) => {
const data = {
[question.id]: value,
};
if (storedResponseValue === value) {
goToNextQuestion(data);
setAnswer("dismissed");
return;
}
onSubmit(data);
setAnswer("dismissed");
};
return (
<div>
<Headline headline={question.headline} questionId={question.id} />
@@ -26,9 +57,7 @@ export default function ConsentQuestion({
<form
onSubmit={(e) => {
e.preventDefault();
const checkbox = document.getElementById(question.id) as HTMLInputElement;
onSubmit({ [question.id]: checkbox.checked ? "accepted" : "dismissed" });
handleSumbit(answer);
}}>
<label className="fb-relative fb-z-10 fb-mt-4 fb-flex fb-w-full fb-cursor-pointer fb-items-center fb-rounded-md fb-border fb-border-gray-200 fb-bg-slate-50 fb-p-4 fb-text-sm focus:fb-outline-none">
<input
@@ -36,6 +65,8 @@ export default function ConsentQuestion({
id={question.id}
name={question.id}
value={question.label}
onChange={handleOnChange}
checked={answer === "accepted"}
className="fb-h-4 fb-w-4 fb-border fb-border-slate-300 focus:fb-ring-0 focus:fb-ring-offset-0"
aria-labelledby={`${question.id}-label`}
style={{ borderColor: brandColor, color: brandColor }}
@@ -46,7 +77,17 @@ export default function ConsentQuestion({
</span>
</label>
<div className="fb-mt-4 fb-flex fb-w-full fb-justify-end">
<div className="fb-mt-4 fb-flex fb-w-full fb-justify-between">
{goToPreviousQuestion && (
<BackButton
onClick={() =>
goToPreviousQuestion({
[question.id]: answer,
})
}
/>
)}
<div />
<SubmitButton
brandColor={brandColor}
question={question}

View File

@@ -9,7 +9,7 @@ export default function FormbricksSignature() {
<p className="fb-text-xs fb-text-slate-400">
Powered by{" "}
<b>
<span className="fb-text-slate-500 fb-hover:text-slate-700">Formbricks</span>
<span className="fb-text-slate-500 hover:fb-text-slate-700">Formbricks</span>
</b>
</p>
</a>

View File

@@ -12,7 +12,7 @@ export default function Headline({
return (
<label
htmlFor={questionId}
className="fb-mb-1.5 fb-block fb-text-base fb-font-semibold fb-leading-6 fb-mr-8 text-slate-900"
className="fb-mb-1.5 fb-block fb-text-base fb-font-semibold fb-leading-6 fb-mr-8 fb-text-slate-900"
style={style}>
{headline}
</label>

View File

@@ -5,7 +5,7 @@ export default function HtmlBody({ htmlString, questionId }: { htmlString?: stri
return (
<label
htmlFor={questionId}
className="fb-block fb-text-sm fb-font-normal fb-leading-6 text-slate-600"
className="fb-block fb-text-sm fb-font-normal fb-leading-6 fb-text-slate-600"
dangerouslySetInnerHTML={{ __html: cleanHtml(htmlString) }}></label>
);
}

View File

@@ -62,7 +62,7 @@ export default function Modal({
aria-live="assertive"
className={cn(
isCenter ? "fb-pointer-events-auto" : "fb-pointer-events-none",
"fb-fixed fb-inset-0 fb-flex fb-items-end fb-z-40 fb-p-3 sm:fb-p-0"
"fb-fixed fb-inset-0 fb-flex fb-items-end fb-z-999999 fb-p-3 sm:fb-p-0"
)}>
<div
className={cn(
@@ -84,7 +84,7 @@ export default function Modal({
<button
type="button"
onClick={close}
class="fb-rounded-md fb-bg-white fb-relative fb-z-50 focus:fb-outline-none focus:fb-ring-2 focus:fb-ring-offset-2 fb-text-slate-400 hover:fb-text-slate-500 focus:ring-slate-500">
class="fb-rounded-md fb-bg-white fb-relative focus:fb-outline-none focus:fb-ring-2 focus:fb-ring-offset-2 fb-text-slate-400 hover:fb-text-slate-500 focus:ring-slate-500">
<span class="fb-sr-only">Close</span>
<svg
class="fb-h-6 fb-w-6"

View File

@@ -6,12 +6,17 @@ import { cn, shuffleArray } from "../lib/utils";
import Headline from "./Headline";
import Subheader from "./Subheader";
import SubmitButton from "./SubmitButton";
import _ from "lodash";
import { BackButton } from "./BackButton";
interface MultipleChoiceMultiProps {
question: TSurveyMultipleChoiceMultiQuestion;
onSubmit: (data: TResponseData) => void;
lastQuestion: boolean;
brandColor: string;
storedResponseValue: string[] | null;
goToNextQuestion: (answer: TResponseData) => void;
goToPreviousQuestion?: (answer: TResponseData) => void;
}
export default function MultipleChoiceMultiQuestion({
@@ -19,6 +24,9 @@ export default function MultipleChoiceMultiQuestion({
onSubmit,
lastQuestion,
brandColor,
storedResponseValue,
goToNextQuestion,
goToPreviousQuestion,
}: MultipleChoiceMultiProps) {
const [selectedChoices, setSelectedChoices] = useState<string[]>([]);
const [showOther, setShowOther] = useState(false);
@@ -36,11 +44,29 @@ export default function MultipleChoiceMultiQuestion({
return selectedChoices.length > 0 || otherSpecified.length > 0;
};
const nonOtherChoiceLabels = question.choices
.filter((label) => label.id !== "other")
.map((choice) => choice.label);
useEffect(() => {
const nonOtherSavedChoices = storedResponseValue?.filter((answer) => nonOtherChoiceLabels.includes(answer));
const savedOtherSpecified = storedResponseValue?.find((answer) => !nonOtherChoiceLabels.includes(answer));
setSelectedChoices(nonOtherSavedChoices ?? []);
if (savedOtherSpecified) {
setOtherSpecified(savedOtherSpecified);
setShowOther(true);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [storedResponseValue, question.id]);
useEffect(() => {
if (showOther && otherInputRef.current) {
otherInputRef.current.value = otherSpecified ?? "";
otherInputRef.current.focus();
}
}, [showOther]);
}, [otherSpecified, showOther]);
useEffect(() => {
setQuestionChoices(
@@ -52,27 +78,38 @@ export default function MultipleChoiceMultiQuestion({
);
}, [question.choices, question.shuffleOption]);
const resetForm = () => {
setSelectedChoices([]); // reset value
setShowOther(false);
setOtherSpecified("");
};
const handleSubmit = () => {
const data = {
[question.id]: selectedChoices,
};
if (_.xor(selectedChoices, storedResponseValue).length === 0) {
goToNextQuestion(data);
return;
}
if (question.required && selectedChoices.length <= 0) {
return;
}
onSubmit(data);
};
return (
<form
onSubmit={(e) => {
e.preventDefault();
if (otherSpecified.length > 0 && showOther) {
selectedChoices.push(otherSpecified);
}
if (question.required && selectedChoices.length <= 0) {
return;
}
const data = {
[question.id]: selectedChoices,
};
onSubmit(data);
setSelectedChoices([]); // reset value
setShowOther(false);
setOtherSpecified("");
handleSubmit();
resetForm();
}}>
<Headline headline={question.headline} questionId={question.id} />
<Subheader subheader={question.subheader} questionId={question.id} />
@@ -145,6 +182,19 @@ export default function MultipleChoiceMultiQuestion({
value={isAtLeastOneChecked() ? "checked" : ""}
/>
<div className="fb-mt-4 fb-flex fb-w-full fb-justify-between">
{goToPreviousQuestion && (
<BackButton
onClick={() => {
if (otherSpecified.length > 0 && showOther) {
selectedChoices.push(otherSpecified);
}
goToPreviousQuestion({
[question.id]: selectedChoices,
});
resetForm();
}}
/>
)}
<div></div>
<SubmitButton
question={question}

View File

@@ -6,12 +6,16 @@ import { cn, shuffleArray } from "../lib/utils";
import Headline from "./Headline";
import Subheader from "./Subheader";
import SubmitButton from "./SubmitButton";
import { BackButton } from "./BackButton";
interface MultipleChoiceSingleProps {
question: TSurveyMultipleChoiceSingleQuestion;
onSubmit: (data: TResponseData) => void;
lastQuestion: boolean;
brandColor: string;
storedResponseValue: string | null;
goToNextQuestion: (answer: TResponseData) => void;
goToPreviousQuestion?: (answer: TResponseData) => void;
}
export default function MultipleChoiceSingleQuestion({
@@ -19,8 +23,13 @@ export default function MultipleChoiceSingleQuestion({
onSubmit,
lastQuestion,
brandColor,
storedResponseValue,
goToNextQuestion,
goToPreviousQuestion,
}: MultipleChoiceSingleProps) {
const storedResponseValueValue = question.choices.find((choice) => choice.label === storedResponseValue)?.id;
const [selectedChoice, setSelectedChoice] = useState<string | null>(null);
const [savedOtherAnswer, setSavedOtherAnswer] = useState<string | null>(null);
const [questionChoices, setQuestionChoices] = useState<TSurveyChoice[]>(
question.choices
? question.shuffleOption && question.shuffleOption !== "none"
@@ -30,11 +39,24 @@ export default function MultipleChoiceSingleQuestion({
);
const otherSpecify = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!storedResponseValueValue) {
const otherChoiceId = question.choices.find((choice) => choice.id === "other")?.id;
if (otherChoiceId && storedResponseValue) {
setSelectedChoice(otherChoiceId);
setSavedOtherAnswer(storedResponseValue);
}
} else {
setSelectedChoice(storedResponseValueValue);
}
}, [question.choices, storedResponseValue, storedResponseValueValue]);
useEffect(() => {
if (selectedChoice === "other") {
otherSpecify.current.value = savedOtherAnswer ?? "";
otherSpecify.current?.focus();
}
}, [selectedChoice]);
}, [savedOtherAnswer, selectedChoice]);
useEffect(() => {
setQuestionChoices(
@@ -46,18 +68,31 @@ export default function MultipleChoiceSingleQuestion({
);
}, [question.choices, question.shuffleOption]);
const resetForm = () => {
setSelectedChoice(null);
setSavedOtherAnswer(null);
};
const handleSubmit = (value: string) => {
const data = {
[question.id]: value,
};
if (value === storedResponseValue) {
goToNextQuestion(data);
resetForm(); // reset form
return;
}
onSubmit(data);
resetForm(); // reset form
};
return (
<form
onSubmit={(e) => {
e.preventDefault();
const value = otherSpecify.current?.value || e.currentTarget[question.id].value;
const data = {
[question.id]: value,
};
onSubmit(data);
setSelectedChoice(null); // reset form
handleSubmit(value);
}}>
<Headline headline={question.headline} questionId={question.id} />
<Subheader subheader={question.subheader} questionId={question.id} />
@@ -110,6 +145,21 @@ export default function MultipleChoiceSingleQuestion({
</fieldset>
</div>
<div className="fb-mt-4 fb-flex fb-w-full fb-justify-between">
{goToPreviousQuestion && (
<BackButton
onClick={() => {
goToPreviousQuestion(
selectedChoice === "other"
? {
[question.id]: otherSpecify.current?.value,
}
: {
[question.id]: question.choices.find((choice) => choice.id === selectedChoice)?.label,
}
);
}}
/>
)}
<div></div>
<SubmitButton
question={question}

View File

@@ -1,21 +1,49 @@
import { h } from "preact";
import { useState } from "preact/hooks";
import { useEffect, useState } from "preact/hooks";
import { TResponseData } from "../../../types/v1/responses";
import type { TSurveyNPSQuestion } from "../../../types/v1/surveys";
import { cn } from "../lib/utils";
import Headline from "./Headline";
import Subheader from "./Subheader";
import SubmitButton from "./SubmitButton";
import { BackButton } from "./BackButton";
interface NPSQuestionProps {
question: TSurveyNPSQuestion;
onSubmit: (data: TResponseData) => void;
lastQuestion: boolean;
brandColor: string;
storedResponseValue: number | null;
goToNextQuestion: (answer: TResponseData) => void;
goToPreviousQuestion?: (answer?: TResponseData) => void;
}
export default function NPSQuestion({ question, onSubmit, lastQuestion, brandColor }: NPSQuestionProps) {
export default function NPSQuestion({
question,
onSubmit,
lastQuestion,
brandColor,
storedResponseValue,
goToNextQuestion,
goToPreviousQuestion,
}: NPSQuestionProps) {
const [selectedChoice, setSelectedChoice] = useState<number | null>(null);
useEffect(() => {
setSelectedChoice(storedResponseValue);
}, [storedResponseValue, question]);
const handleSubmit = (value: number | null) => {
const data = {
[question.id]: value,
};
if (storedResponseValue === value) {
setSelectedChoice(null);
goToNextQuestion(data);
return;
}
setSelectedChoice(null);
onSubmit(data);
};
const handleSelect = (number: number) => {
setSelectedChoice(number);
@@ -31,15 +59,7 @@ export default function NPSQuestion({ question, onSubmit, lastQuestion, brandCol
<form
onSubmit={(e) => {
e.preventDefault();
const data = {};
if (selectedChoice !== null) {
data[question.id] = selectedChoice;
}
setSelectedChoice(null);
onSubmit(data);
// reset form
handleSubmit(selectedChoice);
}}>
<Headline headline={question.headline} questionId={question.id} />
<Subheader subheader={question.subheader} questionId={question.id} />
@@ -58,6 +78,7 @@ export default function NPSQuestion({ question, onSubmit, lastQuestion, brandCol
type="radio"
name="nps"
value={number}
checked={selectedChoice === number}
className="fb-absolute fb-h-full fb-w-full fb-cursor-pointer fb-opacity-0"
onClick={() => handleSelect(number)}
required={question.required}
@@ -72,17 +93,31 @@ export default function NPSQuestion({ question, onSubmit, lastQuestion, brandCol
</div>
</fieldset>
</div>
{!question.required && (
<div className="fb-mt-4 fb-flex fb-w-full fb-justify-between">
<div></div>
<div className="fb-mt-4 fb-flex fb-w-full fb-justify-between">
{goToPreviousQuestion && (
<BackButton
onClick={() => {
goToPreviousQuestion(
storedResponseValue !== selectedChoice
? {
[question.id]: selectedChoice,
}
: undefined
);
}}
/>
)}
<div></div>
{(!question.required || storedResponseValue) && (
<SubmitButton
question={question}
lastQuestion={lastQuestion}
brandColor={brandColor}
onClick={() => {}}
/>
</div>
)}
)}
</div>
</form>
);
}

View File

@@ -4,12 +4,17 @@ import type { TSurveyOpenTextQuestion } from "../../../types/v1/surveys";
import Headline from "./Headline";
import Subheader from "./Subheader";
import SubmitButton from "./SubmitButton";
import { useEffect, useState } from "preact/hooks";
import { BackButton } from "./BackButton";
interface OpenTextQuestionProps {
question: TSurveyOpenTextQuestion;
onSubmit: (data: TResponseData) => void;
lastQuestion: boolean;
brandColor: string;
storedResponseValue: string | null;
goToNextQuestion: (answer: TResponseData) => void;
goToPreviousQuestion?: (answer: TResponseData) => void;
}
export default function OpenTextQuestion({
@@ -17,17 +22,33 @@ export default function OpenTextQuestion({
onSubmit,
lastQuestion,
brandColor,
storedResponseValue,
goToNextQuestion,
goToPreviousQuestion,
}: OpenTextQuestionProps) {
const [value, setValue] = useState<string>("");
useEffect(() => {
setValue(storedResponseValue ?? "");
}, [storedResponseValue, question.id]);
const handleSubmit = (value: string) => {
const data = {
[question.id]: value,
};
if (storedResponseValue === value) {
goToNextQuestion(data);
return;
}
onSubmit(data);
setValue(""); // reset value
};
return (
<form
onSubmit={(e) => {
e.preventDefault();
const data = {
[question.id]: e.currentTarget[question.id].value,
};
e.currentTarget[question.id].value = ""; // reset value
onSubmit(data);
// reset form
handleSubmit(value);
}}>
<Headline headline={question.headline} questionId={question.id} />
<Subheader subheader={question.subheader} questionId={question.id} />
@@ -36,8 +57,10 @@ export default function OpenTextQuestion({
<input
name={question.id}
id={question.id}
placeholder={question.placeholder}
placeholder={!storedResponseValue ? question.placeholder : undefined}
required={question.required}
value={value}
onInput={(e) => setValue(e.currentTarget.value)}
className="fb-block fb-w-full fb-rounded-md fb-border fb-p-2 fb-shadow-sm focus:fb-ring-0 sm:fb-text-sm fb-bg-slate-50 fb-border-slate-100 focus:fb-border-slate-500 focus:fb-outline-none"
/>
) : (
@@ -45,12 +68,23 @@ export default function OpenTextQuestion({
rows={3}
name={question.id}
id={question.id}
placeholder={question.placeholder}
placeholder={!storedResponseValue ? question.placeholder : undefined}
required={question.required}
value={value}
onInput={(e) => setValue(e.currentTarget.value)}
className="fb-block fb-w-full fb-rounded-md fb-border fb-p-2 fb-shadow-sm focus:fb-ring-0 sm:fb-text-sm fb-bg-slate-50 fb-border-slate-100 focus:fb-border-slate-500"></textarea>
)}
</div>
<div className="fb-mt-4 fb-flex fb-w-full fb-justify-between">
{goToPreviousQuestion && (
<BackButton
onClick={() => {
goToPreviousQuestion({
[question.id]: value,
});
}}
/>
)}
<div></div>
<SubmitButton
question={question}

View File

@@ -14,6 +14,9 @@ interface QuestionConditionalProps {
onSubmit: (data: { [x: string]: any }) => void;
lastQuestion: boolean;
brandColor: string;
storedResponseValue: any;
goToNextQuestion: (answer: any) => void;
goToPreviousQuestion?: (answer: any) => void;
}
export default function QuestionConditional({
@@ -21,6 +24,9 @@ export default function QuestionConditional({
onSubmit,
lastQuestion,
brandColor,
storedResponseValue,
goToNextQuestion,
goToPreviousQuestion,
}: QuestionConditionalProps) {
return question.type === QuestionType.OpenText ? (
<OpenTextQuestion
@@ -28,6 +34,9 @@ export default function QuestionConditional({
onSubmit={onSubmit}
lastQuestion={lastQuestion}
brandColor={brandColor}
storedResponseValue={storedResponseValue}
goToNextQuestion={goToNextQuestion}
goToPreviousQuestion={goToPreviousQuestion}
/>
) : question.type === QuestionType.MultipleChoiceSingle ? (
<MultipleChoiceSingleQuestion
@@ -35,6 +44,9 @@ export default function QuestionConditional({
onSubmit={onSubmit}
lastQuestion={lastQuestion}
brandColor={brandColor}
storedResponseValue={storedResponseValue}
goToNextQuestion={goToNextQuestion}
goToPreviousQuestion={goToPreviousQuestion}
/>
) : question.type === QuestionType.MultipleChoiceMulti ? (
<MultipleChoiceMultiQuestion
@@ -42,6 +54,9 @@ export default function QuestionConditional({
onSubmit={onSubmit}
lastQuestion={lastQuestion}
brandColor={brandColor}
storedResponseValue={storedResponseValue}
goToNextQuestion={goToNextQuestion}
goToPreviousQuestion={goToPreviousQuestion}
/>
) : question.type === QuestionType.NPS ? (
<NPSQuestion
@@ -49,6 +64,9 @@ export default function QuestionConditional({
onSubmit={onSubmit}
lastQuestion={lastQuestion}
brandColor={brandColor}
storedResponseValue={storedResponseValue}
goToNextQuestion={goToNextQuestion}
goToPreviousQuestion={goToPreviousQuestion}
/>
) : question.type === QuestionType.CTA ? (
<CTAQuestion
@@ -56,6 +74,9 @@ export default function QuestionConditional({
onSubmit={onSubmit}
lastQuestion={lastQuestion}
brandColor={brandColor}
storedResponseValue={storedResponseValue}
goToNextQuestion={goToNextQuestion}
goToPreviousQuestion={goToPreviousQuestion}
/>
) : question.type === QuestionType.Rating ? (
<RatingQuestion
@@ -63,6 +84,9 @@ export default function QuestionConditional({
onSubmit={onSubmit}
lastQuestion={lastQuestion}
brandColor={brandColor}
storedResponseValue={storedResponseValue}
goToNextQuestion={goToNextQuestion}
goToPreviousQuestion={goToPreviousQuestion}
/>
) : question.type === "consent" ? (
<ConsentQuestion
@@ -70,6 +94,9 @@ export default function QuestionConditional({
onSubmit={onSubmit}
lastQuestion={lastQuestion}
brandColor={brandColor}
storedResponseValue={storedResponseValue}
goToNextQuestion={goToNextQuestion}
goToPreviousQuestion={goToPreviousQuestion}
/>
) : null;
}

View File

@@ -1,5 +1,5 @@
import { h } from "preact";
import { useState } from "preact/hooks";
import { useEffect, useState } from "preact/hooks";
import { TResponseData } from "../../../types/v1/responses";
import type { TSurveyRatingQuestion } from "../../../types/v1/surveys";
import { cn } from "../lib/utils";
@@ -18,12 +18,16 @@ import {
} from "./Smileys";
import Subheader from "./Subheader";
import SubmitButton from "./SubmitButton";
import { BackButton } from "./BackButton";
interface RatingQuestionProps {
question: TSurveyRatingQuestion;
onSubmit: (data: TResponseData) => void;
lastQuestion: boolean;
brandColor: string;
storedResponseValue: number | null;
goToNextQuestion: (answer: TResponseData) => void;
goToPreviousQuestion?: (answer?: TResponseData) => void;
}
export default function RatingQuestion({
@@ -31,10 +35,30 @@ export default function RatingQuestion({
onSubmit,
lastQuestion,
brandColor,
storedResponseValue,
goToNextQuestion,
goToPreviousQuestion,
}: RatingQuestionProps) {
const [selectedChoice, setSelectedChoice] = useState<number | null>(null);
const [hoveredNumber, setHoveredNumber] = useState(0);
useEffect(() => {
setSelectedChoice(storedResponseValue);
}, [storedResponseValue, question]);
const handleSubmit = (value: number | null) => {
const data = {
[question.id]: value,
};
if (storedResponseValue === value) {
goToNextQuestion(data);
setSelectedChoice(null);
return;
}
onSubmit(data);
setSelectedChoice(null);
};
const handleSelect = (number: number) => {
setSelectedChoice(number);
if (question.required) {
@@ -53,6 +77,7 @@ export default function RatingQuestion({
className="fb-absolute fb-h-full fb-w-full fb-cursor-pointer fb-opacity-0 fb-left-0"
onChange={() => handleSelect(number)}
required={question.required}
checked={selectedChoice === number}
/>
);
@@ -60,15 +85,7 @@ export default function RatingQuestion({
<form
onSubmit={(e) => {
e.preventDefault();
const data = {};
if (selectedChoice !== null) {
data[question.id] = selectedChoice;
}
setSelectedChoice(null); // reset choice
onSubmit(data);
handleSubmit(selectedChoice);
}}>
<Headline headline={question.headline} questionId={question.id} />
<Subheader subheader={question.subheader} questionId={question.id} />
@@ -149,17 +166,25 @@ export default function RatingQuestion({
</div>
</fieldset>
</div>
{!question.required && (
<div className="fb-mt-4 fb-flex fb-w-full fb-justify-between">
<div></div>
<div className="fb-mt-4 fb-flex fb-w-full fb-justify-between">
{goToPreviousQuestion && (
<BackButton
onClick={() => {
goToPreviousQuestion({ [question.id]: selectedChoice });
}}
/>
)}
<div></div>
{(!question.required || selectedChoice) && (
<SubmitButton
question={question}
lastQuestion={lastQuestion}
brandColor={brandColor}
onClick={() => {}}
/>
</div>
)}
)}
</div>
</form>
);
}

View File

@@ -2,7 +2,7 @@ import { h } from "preact";
export default function Subheader({ subheader, questionId }: { subheader?: string; questionId: string }) {
return (
<label htmlFor={questionId} className="fb-block fb-text-sm fb-font-normal fb-leading-6 text-slate-600">
<label htmlFor={questionId} className="fb-block fb-text-sm fb-font-normal fb-leading-6 fb-text-slate-600">
{subheader}
</label>
);

View File

@@ -13,6 +13,7 @@ import QuestionConditional from "./QuestionConditional";
import ThankYouCard from "./ThankYouCard";
import FormbricksSignature from "./FormbricksSignature";
import type { TResponseData, TResponseInput } from "../../../types/v1/responses";
import { clearStoredResponse, getStoredResponse, storeResponse } from "../lib/localStorage";
interface SurveyViewProps {
config: TJsConfig;
@@ -28,12 +29,16 @@ export default function SurveyView({ config, survey, close, errorHandler }: Surv
const [displayId, setDisplayId] = useState<string | null>(null);
const [loadingElement, setLoadingElement] = useState(false);
const contentRef = useRef(null);
const [finished, setFinished] = useState(false);
const [storedResponseValue, setStoredResponseValue] = useState<any>(null);
const [countdownProgress, setCountdownProgress] = useState(100);
const [countdownStop, setCountdownStop] = useState(false);
const startRef = useRef(performance.now());
const frameRef = useRef<number | null>(null);
const showBackButton = progress !== 0 && !finished;
const handleStopCountdown = () => {
if (frameRef.current !== null) {
setCountdownStop(true);
@@ -101,84 +106,124 @@ export default function SurveyView({ config, survey, close, errorHandler }: Surv
}
}, [activeQuestionId, survey]);
function evaluateCondition(logic: TSurveyLogic, answerValue: any): boolean {
function evaluateCondition(logic: TSurveyLogic, responseValue: any): boolean {
switch (logic.condition) {
case "equals":
return (
(Array.isArray(answerValue) && answerValue.length === 1 && answerValue.includes(logic.value)) ||
answerValue.toString() === logic.value
(Array.isArray(responseValue) && responseValue.length === 1 && responseValue.includes(logic.value)) ||
responseValue.toString() === logic.value
);
case "notEquals":
return answerValue !== logic.value;
return responseValue !== logic.value;
case "lessThan":
return logic.value !== undefined && answerValue < logic.value;
return logic.value !== undefined && responseValue < logic.value;
case "lessEqual":
return logic.value !== undefined && answerValue <= logic.value;
return logic.value !== undefined && responseValue <= logic.value;
case "greaterThan":
return logic.value !== undefined && answerValue > logic.value;
return logic.value !== undefined && responseValue > logic.value;
case "greaterEqual":
return logic.value !== undefined && answerValue >= logic.value;
return logic.value !== undefined && responseValue >= logic.value;
case "includesAll":
return (
Array.isArray(answerValue) &&
Array.isArray(responseValue) &&
Array.isArray(logic.value) &&
logic.value.every((v) => answerValue.includes(v))
logic.value.every((v) => responseValue.includes(v))
);
case "includesOne":
return (
Array.isArray(answerValue) &&
Array.isArray(responseValue) &&
Array.isArray(logic.value) &&
logic.value.some((v) => answerValue.includes(v))
logic.value.some((v) => responseValue.includes(v))
);
case "accepted":
return answerValue === "accepted";
return responseValue === "accepted";
case "clicked":
return answerValue === "clicked";
return responseValue === "clicked";
case "submitted":
if (typeof answerValue === "string") {
return answerValue !== "dismissed" && answerValue !== "" && answerValue !== null;
} else if (Array.isArray(answerValue)) {
return answerValue.length > 0;
} else if (typeof answerValue === "number") {
return answerValue !== null;
if (typeof responseValue === "string") {
return responseValue !== "dismissed" && responseValue !== "" && responseValue !== null;
} else if (Array.isArray(responseValue)) {
return responseValue.length > 0;
} else if (typeof responseValue === "number") {
return responseValue !== null;
}
return false;
case "skipped":
return (
(Array.isArray(answerValue) && answerValue.length === 0) ||
answerValue === "" ||
answerValue === null ||
answerValue === "dismissed"
(Array.isArray(responseValue) && responseValue.length === 0) ||
responseValue === "" ||
responseValue === null ||
responseValue === "dismissed"
);
default:
return false;
}
}
function getNextQuestion(answer: any): string {
function getNextQuestionId() {
const questions = survey.questions;
const currentQuestionIndex = questions.findIndex((q) => q.id === activeQuestionId);
if (currentQuestionIndex === -1) throw new Error("Question not found");
const answerValue = answer[activeQuestionId];
const currentQuestion = questions[currentQuestionIndex];
if (currentQuestion.logic && currentQuestion.logic.length > 0) {
for (let logic of currentQuestion.logic) {
if (!logic.destination) continue;
if (evaluateCondition(logic, answerValue)) {
return logic.destination;
}
}
}
return questions[currentQuestionIndex + 1]?.id || "end";
}
function goToNextQuestion(answer: TResponseData): string {
setLoadingElement(true);
const questions = survey.questions;
const nextQuestionId = getNextQuestionId();
if (nextQuestionId === "end") {
submitResponse(answer);
return;
}
const nextQuestion = questions.find((q) => q.id === nextQuestionId);
if (!nextQuestion) throw new Error("Question not found");
setStoredResponseValue(getStoredResponse(survey.id, nextQuestionId));
setActiveQuestionId(nextQuestionId);
setLoadingElement(false);
}
function getPreviousQuestionId() {
const questions = survey.questions;
const currentQuestionIndex = questions.findIndex((q) => q.id === activeQuestionId);
if (currentQuestionIndex === -1) throw new Error("Question not found");
return questions[currentQuestionIndex - 1]?.id;
}
function goToPreviousQuestion(answer: TResponseData) {
setLoadingElement(true);
const previousQuestionId = getPreviousQuestionId();
if (!previousQuestionId) throw new Error("Question not found");
if (answer) {
storeResponse(survey.id, answer);
}
setStoredResponseValue(getStoredResponse(survey.id, previousQuestionId));
setActiveQuestionId(previousQuestionId);
setLoadingElement(false);
}
const submitResponse = async (data: TResponseData) => {
setLoadingElement(true);
const nextQuestionId = getNextQuestion(data);
const questions = survey.questions;
const nextQuestionId = getNextQuestionId();
const currentQuestion = questions[activeQuestionId];
const responseValue = data[activeQuestionId];
if (currentQuestion?.logic && currentQuestion?.logic.length > 0) {
for (let logic of currentQuestion.logic) {
if (!logic.destination) continue;
if (evaluateCondition(logic, responseValue)) {
return logic.destination;
}
}
}
const finished = nextQuestionId === "end";
// build response
@@ -196,11 +241,15 @@ export default function SurveyView({ config, survey, close, errorHandler }: Surv
createResponse(responseRequest, config),
markDisplayResponded(displayId, config),
]);
response.ok === true ? setResponseId(response.value.id) : errorHandler(response.error);
if (response.ok === true) {
setResponseId(response.value.id);
storeResponse(survey.id, data);
} else {
errorHandler(response.error);
}
} else {
const result = await updateResponse(responseRequest, responseId, config);
storeResponse(survey.id, data);
if (result.ok !== true) {
errorHandler(result.error);
} else if (responseRequest.finished) {
@@ -210,10 +259,12 @@ export default function SurveyView({ config, survey, close, errorHandler }: Surv
setLoadingElement(false);
if (!finished && nextQuestionId !== "end") {
setStoredResponseValue(getStoredResponse(survey.id, nextQuestionId));
setActiveQuestionId(nextQuestionId);
} else {
setProgress(100);
setFinished(true);
clearStoredResponse(survey.id);
if (survey.thankYouCard.enabled) {
setTimeout(() => {
close();
@@ -253,6 +304,9 @@ export default function SurveyView({ config, survey, close, errorHandler }: Surv
lastQuestion={idx === survey.questions.length - 1}
onSubmit={submitResponse}
question={question}
storedResponseValue={storedResponseValue}
goToNextQuestion={goToNextQuestion}
goToPreviousQuestion={showBackButton ? goToPreviousQuestion : undefined}
/>
)
)

View File

@@ -0,0 +1,24 @@
import type { TResponseData } from "../../../types/v1/responses";
export const storeResponse = (surveyId: string, answer: TResponseData) => {
const storedResponse = localStorage.getItem(`formbricks-${surveyId}-responses`);
if (storedResponse) {
const parsedAnswers = JSON.parse(storedResponse);
localStorage.setItem(`formbricks-${surveyId}-responses`, JSON.stringify({ ...parsedAnswers, ...answer }));
} else {
localStorage.setItem(`formbricks-${surveyId}-responses`, JSON.stringify(answer));
}
};
export const getStoredResponse = (surveyId: string, questionId: string): string | null => {
const storedResponse = localStorage.getItem(`formbricks-${surveyId}-responses`);
if (storedResponse) {
const parsedAnswers = JSON.parse(storedResponse);
return parsedAnswers[questionId] || null;
}
return null;
};
export const clearStoredResponse = (surveyId: string) => {
localStorage.removeItem(`formbricks-${surveyId}-responses`);
};

View File

@@ -7,7 +7,11 @@ module.exports = {
},
content: ["./src/**/*.{tsx,ts,jsx,js}"],
theme: {
extend: {},
extend: {
zIndex: {
'999999': '999999',
}
},
},
prefix: "fb-",
plugins: [],

View File

@@ -34,7 +34,7 @@ export interface Response {
formId: string;
customerId: string;
data: {
[name: string]: string | number | string[] | number[] | undefined;
[name: string]: string | number | string[] | number[] | undefined | null;
};
}

View File

@@ -8,6 +8,7 @@ import Button from "./Button";
import { Calendar } from "./Calendar";
import { useRef } from "react";
import { SelectSingleEventHandler } from "react-day-picker";
import { addDays } from "date-fns";
export function DatePicker({
date,
@@ -44,7 +45,7 @@ export function DatePicker({
mode="single"
selected={formattedDate}
disabled={{
before: new Date(),
before: addDays(new Date(), 1),
}}
onSelect={handleDateSelect}
initialFocus

93
pnpm-lock.yaml generated
View File

@@ -1202,7 +1202,7 @@ packages:
'@babel/core': ^7.0.0-0
dependencies:
'@babel/core': 7.22.9
'@babel/helper-create-class-features-plugin': 7.20.5(@babel/core@7.22.9)
'@babel/helper-create-class-features-plugin': 7.22.6(@babel/core@7.22.9)
'@babel/helper-plugin-utils': 7.22.5
transitivePeerDependencies:
- supports-color
@@ -1959,7 +1959,7 @@ packages:
'@babel/core': ^7.0.0-0
dependencies:
'@babel/core': 7.22.9
'@babel/plugin-transform-react-jsx': 7.19.0(@babel/core@7.22.9)
'@babel/plugin-transform-react-jsx': 7.22.5(@babel/core@7.22.9)
dev: true
/@babel/plugin-transform-react-jsx@7.19.0(@babel/core@7.22.9):
@@ -2268,7 +2268,7 @@ packages:
'@babel/helper-plugin-utils': 7.22.5
'@babel/helper-validator-option': 7.22.5
'@babel/plugin-transform-react-display-name': 7.18.6(@babel/core@7.22.9)
'@babel/plugin-transform-react-jsx': 7.19.0(@babel/core@7.22.9)
'@babel/plugin-transform-react-jsx': 7.22.5(@babel/core@7.22.9)
'@babel/plugin-transform-react-jsx-development': 7.18.6(@babel/core@7.22.9)
'@babel/plugin-transform-react-pure-annotations': 7.18.6(@babel/core@7.22.9)
dev: true
@@ -2854,11 +2854,6 @@ packages:
eslint: 8.46.0
eslint-visitor-keys: 3.4.2
/@eslint-community/regexpp@4.5.1:
resolution: {integrity: sha512-Z5ba73P98O1KUYCCJTUeVpja9RcGoMdncZ6T49FCUl2lN38JtCJ+3WgIDBv0AuY4WChU5PmtJmOCTlN6FZTFKQ==}
engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0}
dev: true
/@eslint-community/regexpp@4.6.2:
resolution: {integrity: sha512-pPTNuaAG3QMH+buKyBIGJs3g/S5y0caxw0ygM3YyE6yJFySwiGGSzA+mM3KJ8QQvzeLh3blwgSonkFjgQdxzMw==}
engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0}
@@ -3584,10 +3579,6 @@ packages:
resolution: {integrity: sha512-RmHanbV21saP/6OEPBJ7yJMuys68cIf8OBBWd7+uj40LdpmswVAwe1uzeuFyUsd6SfeITWT3XnQfn6wULeKwDQ==}
dev: false
/@next/env@13.4.8:
resolution: {integrity: sha512-twuSf1klb3k9wXI7IZhbZGtFCWvGD4wXTY2rmvzIgVhXhs7ISThrbNyutBx3jWIL8Y/Hk9+woytFz5QsgtcRKQ==}
dev: false
/@next/eslint-plugin-next@13.4.12:
resolution: {integrity: sha512-6rhK9CdxEgj/j1qvXIyLTWEaeFv7zOK8yJMulz3Owel0uek0U9MJCGzmKgYxM3aAUBo3gKeywCZKyQnJKto60A==}
dependencies:
@@ -5636,10 +5627,6 @@ packages:
parse5: 7.1.2
dev: true
/@types/json-schema@7.0.11:
resolution: {integrity: sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==}
dev: true
/@types/json-schema@7.0.12:
resolution: {integrity: sha512-Hr5Jfhc9eYOQNPYO5WLDq/n4jqijdHNlDXjuAQkkt+mWdQR+XJToOHrsD4cPaMXpn6KO7y2+wM8AZEs8VpBLVA==}
@@ -5871,7 +5858,7 @@ packages:
typescript:
optional: true
dependencies:
'@eslint-community/regexpp': 4.5.1
'@eslint-community/regexpp': 4.6.2
'@typescript-eslint/parser': 6.2.0(eslint@8.46.0)(typescript@5.1.6)
'@typescript-eslint/scope-manager': 6.2.0
'@typescript-eslint/type-utils': 6.2.0(eslint@8.46.0)(typescript@5.1.6)
@@ -6068,7 +6055,7 @@ packages:
peerDependencies:
eslint: ^6.0.0 || ^7.0.0 || ^8.0.0
dependencies:
'@types/json-schema': 7.0.11
'@types/json-schema': 7.0.12
'@types/semver': 7.5.0
'@typescript-eslint/scope-manager': 5.54.0
'@typescript-eslint/types': 5.54.0
@@ -6874,8 +6861,8 @@ packages:
peerDependencies:
postcss: ^8.1.0
dependencies:
browserslist: 4.21.5
caniuse-lite: 1.0.30001466
browserslist: 4.21.9
caniuse-lite: 1.0.30001512
fraction.js: 4.2.0
normalize-range: 0.1.2
picocolors: 1.0.0
@@ -6890,8 +6877,8 @@ packages:
peerDependencies:
postcss: ^8.1.0
dependencies:
browserslist: 4.21.5
caniuse-lite: 1.0.30001466
browserslist: 4.21.9
caniuse-lite: 1.0.30001512
fraction.js: 4.2.0
normalize-range: 0.1.2
picocolors: 1.0.0
@@ -7381,17 +7368,6 @@ packages:
pako: 1.0.11
dev: true
/browserslist@4.21.5:
resolution: {integrity: sha512-tUkiguQGW7S3IhB7N+c2MV/HZPSCPAAiYBZXLsBhFB/PCy6ZKKsZrmBayHV9fdGV/ARIfJ14NkxKzRDjvp7L6w==}
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true
dependencies:
caniuse-lite: 1.0.30001512
electron-to-chromium: 1.4.284
node-releases: 2.0.10
update-browserslist-db: 1.0.10(browserslist@4.21.5)
dev: true
/browserslist@4.21.9:
resolution: {integrity: sha512-M0MFoZzbUrRU4KNfCrDLnvyE7gub+peetoTid3TBIqtunaDJyXlwhakT+/VkvSXcfIzFfK/nkCs4nmyTmxdNSg==}
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
@@ -7633,10 +7609,6 @@ packages:
lodash.uniq: 4.5.0
dev: true
/caniuse-lite@1.0.30001466:
resolution: {integrity: sha512-ewtFBSfWjEmxUgNBSZItFSmVtvk9zkwkl1OfRZlKA8slltRN+/C/tuGVrF9styXkN36Yu3+SeJ1qkXxDEyNZ5w==}
dev: true
/caniuse-lite@1.0.30001512:
resolution: {integrity: sha512-2S9nK0G/mE+jasCUsMPlARhRCts1ebcp2Ji8Y8PWi4NDE1iRdLCnEPHkEfeBrGC45L4isBx5ur3IQ6yTE2mRZw==}
@@ -8949,8 +8921,8 @@ packages:
mimic-response: 3.1.0
dev: false
/dedent@1.5.0:
resolution: {integrity: sha512-3sSQTYoWKGcRHmHl6Y6opLpRJH55bxeGQ0Y1LCI5pZzUXvokVkj0FC4bi7uEwazxA9FQZ0Nv067Zt5kSUvXxEA==}
/dedent@1.5.1:
resolution: {integrity: sha512-+LxW+KLWxu3HW3M2w2ympwtqPrqYRzU8fqi6Fhd18fBALe15blJPI/I4+UHveMVG6lJqB4JNd4UG0S5cnVHwIg==}
peerDependencies:
babel-plugin-macros: ^3.1.0
peerDependenciesMeta:
@@ -9319,10 +9291,6 @@ packages:
jake: 10.8.5
dev: true
/electron-to-chromium@1.4.284:
resolution: {integrity: sha512-M8WEXFuKXMYMVr45fo8mq0wUrrJHheiKZf6BArTKk9ZBYCKJEOU5H8cdWgDT+qCVZf7Na4lVUaZsA+h6uA9+PA==}
dev: true
/electron-to-chromium@1.4.450:
resolution: {integrity: sha512-BLG5HxSELlrMx7dJ2s+8SFlsCtJp37Zpk2VAxyC6CZtbc+9AJeZHfYHbrlSgdXp6saQ8StMqOTEDaBKgA7u1sw==}
@@ -9818,7 +9786,7 @@ packages:
'@babel/eslint-parser': 7.19.1(@babel/core@7.22.9)(eslint@8.46.0)
'@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.22.9)
'@babel/plugin-syntax-decorators': 7.19.0(@babel/core@7.22.9)
'@babel/plugin-syntax-jsx': 7.18.6(@babel/core@7.22.9)
'@babel/plugin-syntax-jsx': 7.22.5(@babel/core@7.22.9)
eslint: 8.46.0
eslint-plugin-compat: 4.1.2(eslint@8.46.0)
eslint-plugin-jest: 25.7.0(@typescript-eslint/eslint-plugin@6.2.0)(eslint@8.46.0)(jest@29.6.2)(typescript@5.1.6)
@@ -12330,7 +12298,7 @@ packages:
'@types/node': 20.4.5
chalk: 4.1.2
co: 4.6.0
dedent: 1.5.0
dedent: 1.5.1
is-generator-fn: 2.1.0
jest-each: 29.6.2
jest-matcher-utils: 29.6.2
@@ -12424,7 +12392,7 @@ packages:
chalk: 4.1.2
diff-sequences: 29.4.3
jest-get-type: 29.4.3
pretty-format: 29.6.1
pretty-format: 29.6.2
dev: true
/jest-diff@29.6.2:
@@ -12793,26 +12761,12 @@ packages:
chalk: 5.3.0
jest: 29.6.2
jest-regex-util: 29.4.3
jest-watcher: 29.6.1
jest-watcher: 29.6.2
slash: 5.1.0
string-length: 5.0.1
strip-ansi: 7.0.1
dev: true
/jest-watcher@29.6.1:
resolution: {integrity: sha512-d4wpjWTS7HEZPaaj8m36QiaP856JthRZkrgcIY/7ISoUWPIillrXM23WPboZVLbiwZBt4/qn2Jke84Sla6JhFA==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
dependencies:
'@jest/test-result': 29.6.2
'@jest/types': 29.6.1
'@types/node': 20.4.5
ansi-escapes: 4.3.2
chalk: 4.1.2
emittery: 0.13.1
jest-util: 29.6.2
string-length: 4.0.2
dev: true
/jest-watcher@29.6.2:
resolution: {integrity: sha512-GZitlqkMkhkefjfN/p3SJjrDaxPflqxEAv3/ik10OirZqJGYH5rPiIsgVcfof0Tdqg3shQGdEIxDBx+B4tuLzA==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
@@ -14659,7 +14613,7 @@ packages:
next: '*'
dependencies:
'@corex/deepmerge': 4.0.43
'@next/env': 13.4.8
'@next/env': 13.4.12
fast-glob: 3.2.12
minimist: 1.2.8
next: 13.4.12(react-dom@18.2.0)(react@18.2.0)
@@ -14828,10 +14782,6 @@ packages:
vm-browserify: 1.1.2
dev: true
/node-releases@2.0.10:
resolution: {integrity: sha512-5GFldHPXVG/YZmFzJvKK2zDSzPKhEp0+ZR5SVaoSag9fsL5YgHbUHDfnG5494ISANDcK4KwPXAx2xqVEydmd7w==}
dev: true
/node-releases@2.0.12:
resolution: {integrity: sha512-QzsYKWhXTWx8h1kIvqfnC++o0pEmpRQA/aenALsL2F4pqNVr7YzcdMlDij5WBnwftRbJCNJL/O7zdKaxKPHqgQ==}
@@ -20205,17 +20155,6 @@ packages:
engines: {node: '>=4'}
dev: true
/update-browserslist-db@1.0.10(browserslist@4.21.5):
resolution: {integrity: sha512-OztqDenkfFkbSG+tRxBeAnCVPckDBcvibKd35yDONx6OU8N7sqgwc7rCbkJ/WcYtVRZ4ba68d6byhC21GFh7sQ==}
hasBin: true
peerDependencies:
browserslist: '>= 4.21.0'
dependencies:
browserslist: 4.21.5
escalade: 3.1.1
picocolors: 1.0.0
dev: true
/update-browserslist-db@1.0.11(browserslist@4.21.9):
resolution: {integrity: sha512-dCwEFf0/oT85M1fHBg4F0jtLwJrutGoHSQXCh7u4o2t1drG+c0a9Flnqww6XUKSfQMPpJBRjU8d4RXB09qtvaA==}
hasBin: true