mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-22 06:00:51 -06:00
Compare commits
23 Commits
@formbrick
...
@formbrick
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73904e11a6 | ||
|
|
a1b447caad | ||
|
|
6b989487b2 | ||
|
|
d60e0c4e5c | ||
|
|
2a3ab3280f | ||
|
|
5b217e5483 | ||
|
|
ec0d3f2fa2 | ||
|
|
ae702ddd06 | ||
|
|
91f78d875b | ||
|
|
08110b0c34 | ||
|
|
42e6601f13 | ||
|
|
a5c33981a0 | ||
|
|
1a90d1b7e8 | ||
|
|
3905c2227e | ||
|
|
5ae7f31d01 | ||
|
|
cb4cd706ad | ||
|
|
2f8257ae62 | ||
|
|
8a5217b39c | ||
|
|
57e6c86e6a | ||
|
|
4519cb8a2d | ||
|
|
b20cda2d06 | ||
|
|
6e8be0c0bd | ||
|
|
c68a9c8d15 |
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
© 2022. All rights reserved.
|
||||
Formbricks GmbH © 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>
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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:
|
||||
"BoxyHQ’s 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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
"BoxyHQ’s 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import Layout from "@/components/shared/LayoutMdx";
|
||||
|
||||
export const meta = {
|
||||
title: "Privacy Policy",
|
||||
description: "Formbricks Privacy Policy",
|
||||
};
|
||||
|
||||
## **1. Introduction**
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# @formbricks/web
|
||||
|
||||
## 0.1.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [a1b447ca]
|
||||
- @formbricks/js@1.0.2
|
||||
|
||||
## 0.1.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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.
|
||||
|
||||
17
apps/web/components/preview/BackButton.tsx
Normal file
17
apps/web/components/preview/BackButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 “Close
|
||||
<br /> survey on date” setting in the Response Options.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@formbricks/web",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"clean": "rimraf .turbo node_modules .next",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
20
packages/js/src/components/BackButton.tsx
Normal file
20
packages/js/src/components/BackButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
)
|
||||
|
||||
24
packages/js/src/lib/localStorage.ts
Normal file
24
packages/js/src/lib/localStorage.ts
Normal 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`);
|
||||
};
|
||||
@@ -7,7 +7,11 @@ module.exports = {
|
||||
},
|
||||
content: ["./src/**/*.{tsx,ts,jsx,js}"],
|
||||
theme: {
|
||||
extend: {},
|
||||
extend: {
|
||||
zIndex: {
|
||||
'999999': '999999',
|
||||
}
|
||||
},
|
||||
},
|
||||
prefix: "fb-",
|
||||
plugins: [],
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
93
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user