mirror of
https://github.com/formbricks/formbricks.git
synced 2026-02-04 10:30:00 -06:00
Merge branch 'main' of https://github.com/formbricks/formbricks into feature/delete-team
This commit is contained in:
8
.github/workflows/cron-weeklySummary.yml
vendored
8
.github/workflows/cron-weeklySummary.yml
vendored
@@ -10,14 +10,14 @@ jobs:
|
||||
cron-weeklySummary:
|
||||
env:
|
||||
APP_URL: ${{ secrets.APP_URL }}
|
||||
CRON_API_KEY: ${{ secrets.CRON_SECRET }}
|
||||
CRON_SECRET: ${{ secrets.CRON_SECRET }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: cURL request
|
||||
if: ${{ secrets.APP_URL && secrets.CRON_SECRET }}
|
||||
if: ${{ env.APP_URL && env.CRON_SECRET }}
|
||||
run: |
|
||||
curl ${{ secrets.APP_URL }}/api/cron/weekly_summary \
|
||||
curl ${{ env.APP_URL }}/api/cron/weekly_summary \
|
||||
-X POST \
|
||||
-H 'content-type: application/json' \
|
||||
-H 'x-api-key: ${{ secrets.CRON_SECRET }}' \
|
||||
-H 'x-api-key: ${{ env.CRON_SECRET }}' \
|
||||
--fail
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"dependencies": {
|
||||
"@formbricks/js": "workspace:*",
|
||||
"@heroicons/react": "^2.0.18",
|
||||
"next": "13.4.10",
|
||||
"next": "13.4.12",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0"
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -78,6 +78,7 @@ const navigation = [
|
||||
{ title: "Get Webhook", href: "/docs/webhook-api/get-webhook" },
|
||||
{ title: "Create Webhook", href: "/docs/webhook-api/create-webhook" },
|
||||
{ title: "Delete Webhook", href: "/docs/webhook-api/delete-webhook" },
|
||||
{ title: "Webhook Payload", href: "/docs/webhook-api/webhook-payload" },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -11,22 +11,22 @@
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@calcom/embed-react": "^1.2.2",
|
||||
"@calcom/embed-react": "^1.3.0",
|
||||
"@docsearch/react": "^3.5.1",
|
||||
"@formbricks/lib": "workspace:*",
|
||||
"@formbricks/types": "workspace:*",
|
||||
"@formbricks/ui": "workspace:*",
|
||||
"@headlessui/react": "^1.7.15",
|
||||
"@headlessui/react": "^1.7.16",
|
||||
"@heroicons/react": "^2.0.18",
|
||||
"@mapbox/rehype-prism": "^0.8.0",
|
||||
"@mdx-js/loader": "^2.3.0",
|
||||
"@mdx-js/react": "^2.3.0",
|
||||
"@next/mdx": "^13.4.10",
|
||||
"@next/mdx": "^13.4.12",
|
||||
"@paralleldrive/cuid2": "^2.2.1",
|
||||
"clsx": "^2.0.0",
|
||||
"lottie-web": "^5.12.2",
|
||||
"next": "13.4.10",
|
||||
"next-plausible": "^3.10.0",
|
||||
"next": "13.4.12",
|
||||
"next-plausible": "^3.10.1",
|
||||
"next-sitemap": "^4.1.8",
|
||||
"prism-react-renderer": "^2.0.6",
|
||||
"prismjs": "^1.29.0",
|
||||
@@ -35,7 +35,7 @@
|
||||
"react-icons": "^4.10.1",
|
||||
"react-responsive-embed": "^2.1.0",
|
||||
"remark-gfm": "^3.0.1",
|
||||
"sharp": "^0.32.3"
|
||||
"sharp": "^0.32.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@formbricks/tsconfig": "workspace:*",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -8,24 +8,56 @@ export const meta = {
|
||||
"Utilize Docker-Compose for easy deployment on your machine. Clone the repo, configure settings, and build the image to access the app on localhost.",
|
||||
};
|
||||
|
||||
At Formbricks, we understand that different users have different needs, and we strive to cater to a wide variety of situations. This is why we provide two ways of running our application using Docker:
|
||||
At Formbricks, we understand that different users have different needs, and we strive to cater to a wide variety of situations. This is why we provide three ways of running our application:
|
||||
|
||||
1. **Fast Setup with a Pre-built Docker Image:** This method is designed for those who want to quickly set up and start using Formbricks without getting into the technicalities of Docker or the build process. When you choose this method, you're using an image that we've already built for you. The pre-built image is ready-to-run, and it only requires minimal configuration on your part. This approach is perfect for getting a functional instance of Formbricks up and running with minimal hassle. It's as easy as downloading the Docker image and firing up the container.
|
||||
1. **Production Instance Setup with Shell Script on Ubuntu**: If you want to quickly set up a production instance of Formbricks on a server running Ubuntu, we've got you covered! This method utilizes a convenient shell script that takes care of everything, including Docker, Postgres DB, and SSL certificate configuration. The shell script will automatically install all the required dependencies and configure your server, making the process a breeze.
|
||||
|
||||
2. **Manual Setup by Building the Docker Image from Source:** This approach provides the flexibility to configure every aspect of your Formbricks instance, including environment variables that need to be set at build time. While we don't recommend changing the source code of Formbricks, this method allows you to set your own configuration that might be necessary for specific deployment needs. Keep in mind that this method requires a more in-depth understanding of Docker and the build process. However, the trade-off is the additional control and flexibility you gain, making it worth considering if you're a more advanced user or have very specific configuration needs.
|
||||
2. **Fast Setup with a Pre-built Docker Image:** This method is designed for those who want to quickly set up and start using Formbricks without getting into the technicalities of Docker or the build process. When you choose this method, you're using an image that we've already built for you. The pre-built image is ready-to-run, and it only requires minimal configuration on your part. This approach is perfect for getting a functional instance of Formbricks up and running with minimal hassle. It's as easy as downloading the Docker image and firing up the container.
|
||||
|
||||
3. **Manual Setup by Building the Docker Image from Source:** This approach provides the flexibility to configure every aspect of your Formbricks instance, including environment variables that need to be set at build time. While we don't recommend changing the source code of Formbricks, this method allows you to set your own configuration that might be necessary for specific deployment needs. Keep in mind that this method requires a more in-depth understanding of Docker and the build process. However, the trade-off is the additional control and flexibility you gain, making it worth considering if you're a more advanced user or have very specific configuration needs.
|
||||
|
||||
Please note that regardless of the method you choose, Formbricks is designed to be easy-to-use and flexible. So choose the method that best fits your comfort level and requirements, and start leveraging the power of Formbricks today!
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
## (Production: Ubuntu) Running the Shell Script
|
||||
|
||||
This is the quickest way to get Formbricks up and running on an Ubuntu server. The shell script will automatically install all the required dependencies and configure your server, making the process a breeze.
|
||||
|
||||
### Requirements
|
||||
|
||||
Before you proceed, make sure you have the following:
|
||||
|
||||
- A Linux Ubuntu Virtual Machine deployed with SSH access.
|
||||
|
||||
- An A record set up to connect a custom domain to your instance. Formbricks will automatically create an SSL certificate for your domain using Let's Encrypt.
|
||||
|
||||
## Single Command Setup
|
||||
|
||||
Copy and paste the following command into your terminal:
|
||||
|
||||
```bash
|
||||
/bin/sh -c "$(curl -fsSL https://raw.githubusercontent.com/formbricks/formbricks/main/docker/production.sh)"
|
||||
```
|
||||
|
||||
The script will prompt you for the following information:
|
||||
|
||||
1. **Overwriting Docker GPG Keys**: If Docker GPG keys already exist, the script will ask if you want to overwrite them.
|
||||
|
||||
2. **Email Address**: Provide your email address for SSL certificate registration with Let's Encrypt.
|
||||
|
||||
3. **Domain Name**: Enter the domain name that Traefik will use to create the SSL certificate and forward requests to Formbricks.
|
||||
|
||||
That's it! After running the command and providing the required information, visit the domain name you entered, and you should see the Formbricks home wizard!
|
||||
|
||||
## (Most users: Local Setup) Running the pre-built Docker Image
|
||||
|
||||
### Requirements
|
||||
|
||||
Ensure `docker` & `docker compose` are installed on your server/system. Both are typically included with Docker utilities, like Docker Desktop and Rancher Desktop.
|
||||
|
||||
**Note**: `docker compose` without the hyphen is now the primary method of using docker-compose, according to the Docker documentation.
|
||||
|
||||
## (Most users) Running the pre-built Docker Image
|
||||
|
||||
This is suitable for those who are testing Formbricks or running it with minimal to no modifications. For this we use the [public Docker image](https://hub.docker.com/r/formbricks/formbricks) and a simple docker-compose file.
|
||||
|
||||
1. **Create a New Directory for Formbricks**
|
||||
@@ -89,6 +121,12 @@ This is suitable for those who are testing Formbricks or running it with minimal
|
||||
|
||||
## (Advanced users) Build and Run Formbricks
|
||||
|
||||
### Requirements
|
||||
|
||||
Ensure `docker` & `docker compose` are installed on your server/system. Both are typically included with Docker utilities, like Docker Desktop and Rancher Desktop.
|
||||
|
||||
**Note**: `docker compose` without the hyphen is now the primary method of using docker-compose, according to the Docker documentation.
|
||||
|
||||
1. Clone the repository:
|
||||
|
||||
```bash
|
||||
@@ -164,4 +202,16 @@ docker compose logs -f
|
||||
|
||||
You can close the logs again with `CTRL + C`.
|
||||
|
||||
## Troubleshooting for the Shell Script Setup
|
||||
|
||||
If you encounter any issues, consider the following steps:
|
||||
|
||||
- **Inbound Rules**: Make sure you have added inbound rules for Port 80 and 443 in your VM's Security Group.
|
||||
|
||||
- **A Record**: Verify that you have set up an A record for your domain, pointing to your VM's IP address.
|
||||
|
||||
- **Check Docker Instances**: Run `docker ps` to check the status of the Docker instances.
|
||||
|
||||
- **Check Formbricks Logs**: Run `cd formbricks && docker compose logs` to check the logs of the Formbricks stack.
|
||||
|
||||
export default ({ children }) => <Layout meta={meta}>{children}</Layout>;
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Layout } from "@/components/docs/Layout";
|
||||
import { Fence } from "@/components/shared/Fence";
|
||||
|
||||
export const meta = {
|
||||
title: "Webhook Payload",
|
||||
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.
|
||||
|
||||
## An example webhook payload
|
||||
|
||||
```
|
||||
{
|
||||
"webhookId": "cljwxvjos0003qhnvj2jg4k5i",
|
||||
"event": "responseCreated",
|
||||
"data": {
|
||||
"id": "cljwy2m8r0001qhclco1godnu",
|
||||
"createdAt": "2023-07-10T14:14:17.115Z",
|
||||
"updatedAt": "2023-07-10T14:14:17.115Z",
|
||||
"surveyId": "cljsf3d7a000019cv9apt2t27",
|
||||
"finished": false,
|
||||
"data": {
|
||||
"qumbk3fkr6cky8850bvvq5z1": "Executive"
|
||||
},
|
||||
"meta": {
|
||||
"userAgent": {
|
||||
"os": "Mac OS",
|
||||
"browser": "Chrome"
|
||||
}
|
||||
},
|
||||
"personAttributes": {
|
||||
"email": "test@web.com",
|
||||
"userId": "THIS-IS-A-VERY-LONG-USER-ID-FOR-TESTING"
|
||||
},
|
||||
"person": {
|
||||
"id": "cljold01t0000qh8ewzigzmjk",
|
||||
"attributes": {
|
||||
"email": "test@web.com",
|
||||
"userId": "THIS-IS-A-VERY-LONG-USER-ID-FOR-TESTING"
|
||||
},
|
||||
"createdAt": "2023-07-04T17:56:17.154Z",
|
||||
"updatedAt": "2023-07-04T17:56:17.154Z"
|
||||
},
|
||||
"notes": [],
|
||||
"tags": []
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
| Variable | Type | Description |
|
||||
| --------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| webhookId | String | Webhook's Id |
|
||||
| event | String | The name of the trigger event [responseCreated, responseUpdated, responseFinished] |
|
||||
| data | Object | Contains the details of the newly created response. |
|
||||
| data.id | String | Formbricks Response ID. |
|
||||
| data.createdAt | String | The timestamp when the response was created. |
|
||||
| data.updatedAt | String | The timestamp when the response was last updated. |
|
||||
| data.surveyId | String | The identifier of the survey associated with this response. |
|
||||
| data.finished | Boolean | A boolean value indicating whether the survey response is marked as finished. |
|
||||
| data.data | Object | An object containing the response data, where keys are question identifiers, and values are the corresponding answers given by the respondent. |
|
||||
| data.meta | Object | Additional metadata related to the response, such as the user's operating system and browser information. |
|
||||
| data.personAttributes | Object | An object with attributes related to the respondent, such as their email and a user ID (if available). |
|
||||
| data.person | Object | Information about the respondent, including their unique id, attributes, and creation/update timestamps. |
|
||||
| data.notes | Array | An array of notes associated with the response (if any). |
|
||||
| data.tags | Array | An array of tags assigned to the response (if any). |
|
||||
|
||||
export default ({ children }) => <Layout meta={meta}>{children}</Layout>;
|
||||
@@ -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.
|
||||
|
||||
3
apps/web/.gitignore
vendored
3
apps/web/.gitignore
vendored
@@ -34,3 +34,6 @@ yarn-error.log*
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# Sentry Auth Token
|
||||
.sentryclirc
|
||||
|
||||
15
apps/web/CHANGELOG.md
Normal file
15
apps/web/CHANGELOG.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# @formbricks/web
|
||||
|
||||
## 0.1.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [a1b447ca]
|
||||
- @formbricks/js@1.0.2
|
||||
|
||||
## 0.1.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [3d0d633b]
|
||||
- @formbricks/js@1.0.1
|
||||
@@ -36,6 +36,8 @@ COPY --from=installer --chown=nextjs:nodejs /app/packages/database/migrations ./
|
||||
|
||||
ENV NEXTAUTH_SECRET=$NEXTAUTH_SECRET
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD if [ "$NEXTAUTH_SECRET" != "RANDOM_STRING" ]; then \
|
||||
pnpm dlx prisma migrate deploy && node apps/web/server.js; \
|
||||
else \
|
||||
|
||||
@@ -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,10 @@ export default function PreviewSurvey({
|
||||
brandColor={brandColor}
|
||||
lastQuestion={idx === questions.length - 1}
|
||||
onSubmit={gotoNextQuestion}
|
||||
storedResponseValue={storedResponseValue}
|
||||
goToNextQuestion={gotoNextQuestion}
|
||||
goToPreviousQuestion={showBackButton ? goToPreviousQuestion : undefined}
|
||||
autoFocus={false}
|
||||
/>
|
||||
) : null
|
||||
)
|
||||
@@ -307,6 +327,10 @@ 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">
|
||||
|
||||
@@ -12,6 +12,7 @@ interface CTAQuestionFormProps {
|
||||
questionIdx: number;
|
||||
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
|
||||
lastQuestion: boolean;
|
||||
isInValid: boolean;
|
||||
}
|
||||
|
||||
export default function CTAQuestionForm({
|
||||
@@ -19,6 +20,7 @@ export default function CTAQuestionForm({
|
||||
questionIdx,
|
||||
updateQuestion,
|
||||
lastQuestion,
|
||||
isInValid,
|
||||
}: CTAQuestionFormProps): JSX.Element {
|
||||
const [firstRender, setFirstRender] = useState(true);
|
||||
|
||||
@@ -33,6 +35,7 @@ export default function CTAQuestionForm({
|
||||
name="headline"
|
||||
value={question.headline}
|
||||
onChange={(e) => updateQuestion(questionIdx, { headline: e.target.value })}
|
||||
isInvalid={isInValid && question.headline.trim() === ""}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,12 +11,14 @@ interface ConsentQuestionFormProps {
|
||||
question: ConsentQuestion;
|
||||
questionIdx: number;
|
||||
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
|
||||
isInValid: boolean;
|
||||
}
|
||||
|
||||
export default function ConsentQuestionForm({
|
||||
question,
|
||||
questionIdx,
|
||||
updateQuestion,
|
||||
isInValid,
|
||||
}: ConsentQuestionFormProps): JSX.Element {
|
||||
const [firstRender, setFirstRender] = useState(true);
|
||||
return (
|
||||
@@ -29,6 +31,7 @@ export default function ConsentQuestionForm({
|
||||
name="headline"
|
||||
value={question.headline}
|
||||
onChange={(e) => updateQuestion(questionIdx, { headline: e.target.value })}
|
||||
isInvalid={isInValid && question.headline.trim() === ""}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -62,6 +65,7 @@ export default function ConsentQuestionForm({
|
||||
value={question.label}
|
||||
placeholder="I agree to the terms and conditions"
|
||||
onChange={(e) => updateQuestion(questionIdx, { label: e.target.value })}
|
||||
isInvalid={isInValid && question.label.trim() === ""}
|
||||
/>
|
||||
</div>
|
||||
{/* <div className="mt-3">
|
||||
|
||||
@@ -21,12 +21,14 @@ interface OpenQuestionFormProps {
|
||||
questionIdx: number;
|
||||
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
|
||||
lastQuestion: boolean;
|
||||
isInValid: boolean;
|
||||
}
|
||||
|
||||
export default function MultipleChoiceMultiForm({
|
||||
question,
|
||||
questionIdx,
|
||||
updateQuestion,
|
||||
isInValid,
|
||||
}: OpenQuestionFormProps): JSX.Element {
|
||||
const lastChoiceRef = useRef<HTMLInputElement>(null);
|
||||
const [isNew, setIsNew] = useState(true);
|
||||
@@ -51,16 +53,28 @@ export default function MultipleChoiceMultiForm({
|
||||
},
|
||||
};
|
||||
|
||||
const updateChoice = (choiceIdx: number, updatedAttributes: any) => {
|
||||
const newChoices = !question.choices
|
||||
? []
|
||||
: question.choices.map((choice, idx) => {
|
||||
if (idx === choiceIdx) {
|
||||
return { ...choice, ...updatedAttributes };
|
||||
}
|
||||
return choice;
|
||||
});
|
||||
updateQuestion(questionIdx, { choices: newChoices });
|
||||
const updateChoice = (choiceIdx: number, updatedAttributes: { label: string }) => {
|
||||
const newLabel = updatedAttributes.label;
|
||||
const oldLabel = question.choices[choiceIdx].label;
|
||||
let newChoices: any[] = [];
|
||||
if (question.choices) {
|
||||
newChoices = question.choices.map((choice, idx) => {
|
||||
if (idx !== choiceIdx) return choice;
|
||||
return { ...choice, ...updatedAttributes };
|
||||
});
|
||||
}
|
||||
|
||||
let newLogic: any[] = [];
|
||||
question.logic?.forEach((logic) => {
|
||||
let newL: string | string[] | undefined = logic.value;
|
||||
if (Array.isArray(logic.value)) {
|
||||
newL = logic.value.map((value) => (value === oldLabel ? newLabel : value));
|
||||
} else {
|
||||
newL = logic.value === oldLabel ? newLabel : logic.value;
|
||||
}
|
||||
newLogic.push({ ...logic, value: newL });
|
||||
});
|
||||
updateQuestion(questionIdx, { choices: newChoices, logic: newLogic });
|
||||
};
|
||||
|
||||
const addChoice = (choiceIdx?: number) => {
|
||||
@@ -137,6 +151,7 @@ export default function MultipleChoiceMultiForm({
|
||||
name="headline"
|
||||
value={question.headline}
|
||||
onChange={(e) => updateQuestion(questionIdx, { headline: e.target.value })}
|
||||
isInvalid={isInValid && question.headline.trim() === ""}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -184,6 +199,7 @@ export default function MultipleChoiceMultiForm({
|
||||
className={cn(choice.id === "other" && "border-dashed")}
|
||||
placeholder={choice.id === "other" ? "Other" : `Option ${choiceIdx + 1}`}
|
||||
onChange={(e) => updateChoice(choiceIdx, { label: e.target.value })}
|
||||
isInvalid={isInValid && choice.label.trim() === ""}
|
||||
/>
|
||||
{question.choices && question.choices.length > 2 && (
|
||||
<TrashIcon
|
||||
|
||||
@@ -21,12 +21,14 @@ interface OpenQuestionFormProps {
|
||||
questionIdx: number;
|
||||
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
|
||||
lastQuestion: boolean;
|
||||
isInValid: boolean;
|
||||
}
|
||||
|
||||
export default function MultipleChoiceSingleForm({
|
||||
question,
|
||||
questionIdx,
|
||||
updateQuestion,
|
||||
isInValid,
|
||||
}: OpenQuestionFormProps): JSX.Element {
|
||||
const lastChoiceRef = useRef<HTMLInputElement>(null);
|
||||
const [isNew, setIsNew] = useState(true);
|
||||
@@ -51,16 +53,28 @@ export default function MultipleChoiceSingleForm({
|
||||
},
|
||||
};
|
||||
|
||||
const updateChoice = (choiceIdx: number, updatedAttributes: any) => {
|
||||
const newChoices = !question.choices
|
||||
? []
|
||||
: question.choices.map((choice, idx) => {
|
||||
if (idx === choiceIdx) {
|
||||
return { ...choice, ...updatedAttributes };
|
||||
}
|
||||
return choice;
|
||||
});
|
||||
updateQuestion(questionIdx, { choices: newChoices });
|
||||
const updateChoice = (choiceIdx: number, updatedAttributes: { label: string }) => {
|
||||
const newLabel = updatedAttributes.label;
|
||||
const oldLabel = question.choices[choiceIdx].label;
|
||||
let newChoices: any[] = [];
|
||||
if (question.choices) {
|
||||
newChoices = question.choices.map((choice, idx) => {
|
||||
if (idx !== choiceIdx) return choice;
|
||||
return { ...choice, ...updatedAttributes };
|
||||
});
|
||||
}
|
||||
|
||||
let newLogic: any[] = [];
|
||||
question.logic?.forEach((logic) => {
|
||||
let newL: string | string[] | undefined = logic.value;
|
||||
if (Array.isArray(logic.value)) {
|
||||
newL = logic.value.map((value) => (value === oldLabel ? newLabel : value));
|
||||
} else {
|
||||
newL = logic.value === oldLabel ? newLabel : logic.value;
|
||||
}
|
||||
newLogic.push({ ...logic, value: newL });
|
||||
});
|
||||
updateQuestion(questionIdx, { choices: newChoices, logic: newLogic });
|
||||
};
|
||||
|
||||
const addChoice = (choiceIdx?: number) => {
|
||||
@@ -137,6 +151,7 @@ export default function MultipleChoiceSingleForm({
|
||||
name="headline"
|
||||
value={question.headline}
|
||||
onChange={(e) => updateQuestion(questionIdx, { headline: e.target.value })}
|
||||
isInvalid={isInValid && question.headline.trim() === ""}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -184,6 +199,7 @@ export default function MultipleChoiceSingleForm({
|
||||
className={cn(choice.id === "other" && "border-dashed")}
|
||||
placeholder={choice.id === "other" ? "Other" : `Option ${choiceIdx + 1}`}
|
||||
onChange={(e) => updateChoice(choiceIdx, { label: e.target.value })}
|
||||
isInvalid={isInValid && choice.label.trim() === ""}
|
||||
/>
|
||||
{question.choices && question.choices.length > 2 && (
|
||||
<TrashIcon
|
||||
|
||||
@@ -10,6 +10,7 @@ interface NPSQuestionFormProps {
|
||||
questionIdx: number;
|
||||
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
|
||||
lastQuestion: boolean;
|
||||
isInValid: boolean;
|
||||
}
|
||||
|
||||
export default function NPSQuestionForm({
|
||||
@@ -17,6 +18,7 @@ export default function NPSQuestionForm({
|
||||
questionIdx,
|
||||
updateQuestion,
|
||||
lastQuestion,
|
||||
isInValid,
|
||||
}: NPSQuestionFormProps): JSX.Element {
|
||||
const [showSubheader, setShowSubheader] = useState(!!question.subheader);
|
||||
|
||||
@@ -31,6 +33,7 @@ export default function NPSQuestionForm({
|
||||
name="headline"
|
||||
value={question.headline}
|
||||
onChange={(e) => updateQuestion(questionIdx, { headline: e.target.value })}
|
||||
isInvalid={isInValid && question.headline.trim() === ""}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,12 +10,14 @@ interface OpenQuestionFormProps {
|
||||
questionIdx: number;
|
||||
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
|
||||
lastQuestion: boolean;
|
||||
isInValid: boolean;
|
||||
}
|
||||
|
||||
export default function OpenQuestionForm({
|
||||
question,
|
||||
questionIdx,
|
||||
updateQuestion,
|
||||
isInValid,
|
||||
}: OpenQuestionFormProps): JSX.Element {
|
||||
const [showSubheader, setShowSubheader] = useState(!!question.subheader);
|
||||
|
||||
@@ -30,6 +32,7 @@ export default function OpenQuestionForm({
|
||||
name="headline"
|
||||
value={question.headline}
|
||||
onChange={(e) => updateQuestion(questionIdx, { headline: e.target.value })}
|
||||
isInvalid={isInValid && question.headline.trim() === ""}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -39,6 +39,7 @@ interface QuestionCardProps {
|
||||
activeQuestionId: string | null;
|
||||
setActiveQuestionId: (questionId: string | null) => void;
|
||||
lastQuestion: boolean;
|
||||
isInValid: boolean;
|
||||
}
|
||||
|
||||
export default function QuestionCard({
|
||||
@@ -51,6 +52,7 @@ export default function QuestionCard({
|
||||
activeQuestionId,
|
||||
setActiveQuestionId,
|
||||
lastQuestion,
|
||||
isInValid,
|
||||
}: QuestionCardProps) {
|
||||
const question = localSurvey.questions[questionIdx];
|
||||
const open = activeQuestionId === question.id;
|
||||
@@ -69,7 +71,8 @@ export default function QuestionCard({
|
||||
<div
|
||||
className={cn(
|
||||
open ? "bg-slate-700" : "bg-slate-400",
|
||||
"top-0 w-10 rounded-l-lg p-2 text-center text-sm text-white hover:bg-slate-600"
|
||||
"top-0 w-10 rounded-l-lg p-2 text-center text-sm text-white hover:bg-slate-600",
|
||||
isInValid && "bg-red-400 hover:bg-red-600"
|
||||
)}>
|
||||
{questionIdx + 1}
|
||||
</div>
|
||||
@@ -136,6 +139,7 @@ export default function QuestionCard({
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
lastQuestion={lastQuestion}
|
||||
isInValid={isInValid}
|
||||
/>
|
||||
) : question.type === QuestionType.MultipleChoiceSingle ? (
|
||||
<MultipleChoiceSingleForm
|
||||
@@ -144,6 +148,7 @@ export default function QuestionCard({
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
lastQuestion={lastQuestion}
|
||||
isInValid={isInValid}
|
||||
/>
|
||||
) : question.type === QuestionType.MultipleChoiceMulti ? (
|
||||
<MultipleChoiceMultiForm
|
||||
@@ -152,6 +157,7 @@ export default function QuestionCard({
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
lastQuestion={lastQuestion}
|
||||
isInValid={isInValid}
|
||||
/>
|
||||
) : question.type === QuestionType.NPS ? (
|
||||
<NPSQuestionForm
|
||||
@@ -160,6 +166,7 @@ export default function QuestionCard({
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
lastQuestion={lastQuestion}
|
||||
isInValid={isInValid}
|
||||
/>
|
||||
) : question.type === QuestionType.CTA ? (
|
||||
<CTAQuestionForm
|
||||
@@ -168,6 +175,7 @@ export default function QuestionCard({
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
lastQuestion={lastQuestion}
|
||||
isInValid={isInValid}
|
||||
/>
|
||||
) : question.type === QuestionType.Rating ? (
|
||||
<RatingQuestionForm
|
||||
@@ -176,6 +184,7 @@ export default function QuestionCard({
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
lastQuestion={lastQuestion}
|
||||
isInValid={isInValid}
|
||||
/>
|
||||
) : question.type === "consent" ? (
|
||||
<ConsentQuestionForm
|
||||
@@ -183,6 +192,7 @@ export default function QuestionCard({
|
||||
question={question}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
isInValid={isInValid}
|
||||
/>
|
||||
) : null}
|
||||
<div className="mt-4">
|
||||
|
||||
@@ -9,6 +9,8 @@ import AddQuestionButton from "./AddQuestionButton";
|
||||
import EditThankYouCard from "./EditThankYouCard";
|
||||
import QuestionCard from "./QuestionCard";
|
||||
import { StrictModeDroppable } from "./StrictModeDroppable";
|
||||
import { Question } from "@formbricks/types/questions";
|
||||
import { validateQuestion } from "./Validation";
|
||||
|
||||
interface QuestionsViewProps {
|
||||
localSurvey: Survey;
|
||||
@@ -16,6 +18,8 @@ interface QuestionsViewProps {
|
||||
activeQuestionId: string | null;
|
||||
setActiveQuestionId: (questionId: string | null) => void;
|
||||
environmentId: string;
|
||||
invalidQuestions: String[] | null;
|
||||
setInvalidQuestions: (invalidQuestions: String[] | null) => void;
|
||||
}
|
||||
|
||||
export default function QuestionsView({
|
||||
@@ -24,6 +28,8 @@ export default function QuestionsView({
|
||||
localSurvey,
|
||||
setLocalSurvey,
|
||||
environmentId,
|
||||
invalidQuestions,
|
||||
setInvalidQuestions,
|
||||
}: QuestionsViewProps) {
|
||||
const internalQuestionIdMap = useMemo(() => {
|
||||
return localSurvey.questions.reduce((acc, question) => {
|
||||
@@ -44,12 +50,33 @@ export default function QuestionsView({
|
||||
return survey;
|
||||
};
|
||||
|
||||
// function to validate individual questions
|
||||
const validateSurvey = (question: Question) => {
|
||||
// prevent this function to execute further if user hasnt still tried to save the survey
|
||||
if (invalidQuestions === null) {
|
||||
return;
|
||||
}
|
||||
let temp = JSON.parse(JSON.stringify(invalidQuestions));
|
||||
if (validateQuestion(question)) {
|
||||
temp = invalidQuestions.filter((id) => id !== question.id);
|
||||
setInvalidQuestions(temp);
|
||||
} else if (!invalidQuestions.includes(question.id)) {
|
||||
temp.push(question.id);
|
||||
setInvalidQuestions(temp);
|
||||
}
|
||||
};
|
||||
|
||||
const updateQuestion = (questionIdx: number, updatedAttributes: any) => {
|
||||
let updatedSurvey = JSON.parse(JSON.stringify(localSurvey));
|
||||
if ("id" in updatedAttributes) {
|
||||
// if the survey whose id is to be changed is linked to logic of any other survey then changing it
|
||||
const initialQuestionId = updatedSurvey.questions[questionIdx].id;
|
||||
updatedSurvey = handleQuestionLogicChange(updatedSurvey, initialQuestionId, updatedAttributes.id);
|
||||
if (invalidQuestions?.includes(initialQuestionId)) {
|
||||
setInvalidQuestions(
|
||||
invalidQuestions.map((id) => (id === initialQuestionId ? updatedAttributes.id : id))
|
||||
);
|
||||
}
|
||||
|
||||
// relink the question to internal Id
|
||||
internalQuestionIdMap[updatedAttributes.id] =
|
||||
@@ -63,6 +90,7 @@ export default function QuestionsView({
|
||||
...updatedAttributes,
|
||||
};
|
||||
setLocalSurvey(updatedSurvey);
|
||||
validateSurvey(updatedSurvey.questions[questionIdx]);
|
||||
};
|
||||
|
||||
const deleteQuestion = (questionIdx: number) => {
|
||||
@@ -120,7 +148,6 @@ export default function QuestionsView({
|
||||
if (!result.destination) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newQuestions = Array.from(localSurvey.questions);
|
||||
const [reorderedQuestion] = newQuestions.splice(result.source.index, 1);
|
||||
newQuestions.splice(result.destination.index, 0, reorderedQuestion);
|
||||
@@ -134,7 +161,6 @@ export default function QuestionsView({
|
||||
const [reorderedQuestion] = newQuestions.splice(questionIndex, 1);
|
||||
const destinationIndex = up ? questionIndex - 1 : questionIndex + 1;
|
||||
newQuestions.splice(destinationIndex, 0, reorderedQuestion);
|
||||
|
||||
const updatedSurvey = { ...localSurvey, questions: newQuestions };
|
||||
setLocalSurvey(updatedSurvey);
|
||||
};
|
||||
@@ -159,6 +185,7 @@ export default function QuestionsView({
|
||||
activeQuestionId={activeQuestionId}
|
||||
setActiveQuestionId={setActiveQuestionId}
|
||||
lastQuestion={questionIdx === localSurvey.questions.length - 1}
|
||||
isInValid={invalidQuestions ? invalidQuestions.includes(question.id) : false}
|
||||
/>
|
||||
))}
|
||||
{provided.placeholder}
|
||||
|
||||
@@ -12,6 +12,7 @@ interface RatingQuestionFormProps {
|
||||
questionIdx: number;
|
||||
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
|
||||
lastQuestion: boolean;
|
||||
isInValid: boolean;
|
||||
}
|
||||
|
||||
export default function RatingQuestionForm({
|
||||
@@ -19,6 +20,7 @@ export default function RatingQuestionForm({
|
||||
questionIdx,
|
||||
updateQuestion,
|
||||
lastQuestion,
|
||||
isInValid,
|
||||
}: RatingQuestionFormProps) {
|
||||
const [showSubheader, setShowSubheader] = useState(!!question.subheader);
|
||||
|
||||
@@ -33,6 +35,7 @@ export default function RatingQuestionForm({
|
||||
name="headline"
|
||||
value={question.headline}
|
||||
onChange={(e) => updateQuestion(questionIdx, { headline: e.target.value })}
|
||||
isInvalid={isInValid && question.headline.trim() === ""}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -22,7 +22,7 @@ export default function SurveyEditor({ environmentId, surveyId }: SurveyEditorPr
|
||||
const [activeView, setActiveView] = useState<"questions" | "settings">("questions");
|
||||
const [activeQuestionId, setActiveQuestionId] = useState<string | null>(null);
|
||||
const [localSurvey, setLocalSurvey] = useState<Survey | null>();
|
||||
|
||||
const [invalidQuestions, setInvalidQuestions] = useState<String[] | null>(null);
|
||||
const { survey, isLoadingSurvey, isErrorSurvey } = useSurvey(environmentId, surveyId);
|
||||
const { product, isLoadingProduct, isErrorProduct } = useProduct(environmentId);
|
||||
const { environment, isLoadingEnvironment, isErrorEnvironment } = useEnvironment(environmentId);
|
||||
@@ -56,6 +56,7 @@ export default function SurveyEditor({ environmentId, surveyId }: SurveyEditorPr
|
||||
environmentId={environmentId}
|
||||
activeId={activeView}
|
||||
setActiveId={setActiveView}
|
||||
setInvalidQuestions={setInvalidQuestions}
|
||||
/>
|
||||
<div className="relative z-0 flex flex-1 overflow-hidden">
|
||||
<main className="relative z-0 flex-1 overflow-y-auto focus:outline-none">
|
||||
@@ -67,6 +68,8 @@ export default function SurveyEditor({ environmentId, surveyId }: SurveyEditorPr
|
||||
activeQuestionId={activeQuestionId}
|
||||
setActiveQuestionId={setActiveQuestionId}
|
||||
environmentId={environmentId}
|
||||
invalidQuestions={invalidQuestions}
|
||||
setInvalidQuestions={setInvalidQuestions}
|
||||
/>
|
||||
) : (
|
||||
<SettingsView
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { isEqual } from "lodash";
|
||||
import { validateQuestion } from "./Validation";
|
||||
|
||||
interface SurveyMenuBarProps {
|
||||
localSurvey: Survey;
|
||||
@@ -21,6 +22,7 @@ interface SurveyMenuBarProps {
|
||||
environmentId: string;
|
||||
activeId: "questions" | "settings";
|
||||
setActiveId: (id: "questions" | "settings") => void;
|
||||
setInvalidQuestions: (invalidQuestions: String[]) => void;
|
||||
}
|
||||
|
||||
export default function SurveyMenuBar({
|
||||
@@ -30,6 +32,7 @@ export default function SurveyMenuBar({
|
||||
setLocalSurvey,
|
||||
activeId,
|
||||
setActiveId,
|
||||
setInvalidQuestions,
|
||||
}: SurveyMenuBarProps) {
|
||||
const router = useRouter();
|
||||
const { triggerSurveyMutate, isMutatingSurvey } = useSurveyMutation(environmentId, localSurvey.id);
|
||||
@@ -37,6 +40,7 @@ export default function SurveyMenuBar({
|
||||
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [isConfirmDialogOpen, setConfirmDialogOpen] = useState(false);
|
||||
const { product } = useProduct(environmentId);
|
||||
let faultyQuestions: String[] = [];
|
||||
|
||||
useEffect(() => {
|
||||
if (audiencePrompt && activeId === "settings") {
|
||||
@@ -85,6 +89,26 @@ export default function SurveyMenuBar({
|
||||
}
|
||||
};
|
||||
|
||||
const validateSurvey = (survey) => {
|
||||
faultyQuestions = [];
|
||||
for (let index = 0; index < survey.questions.length; index++) {
|
||||
const question = survey.questions[index];
|
||||
const isValid = validateQuestion(question);
|
||||
|
||||
if (!isValid) {
|
||||
faultyQuestions.push(question.id);
|
||||
}
|
||||
}
|
||||
// if there are any faulty questions, the user won't be allowed to save the survey
|
||||
if (faultyQuestions.length > 0) {
|
||||
setInvalidQuestions(faultyQuestions);
|
||||
toast.error("Please fill required fields");
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const saveSurveyAction = (shouldNavigateBack = false) => {
|
||||
// variable named strippedSurvey that is a copy of localSurvey with isDraft removed from every question
|
||||
const strippedSurvey = {
|
||||
@@ -94,6 +118,11 @@ export default function SurveyMenuBar({
|
||||
return rest;
|
||||
}),
|
||||
};
|
||||
|
||||
if (!validateSurvey(localSurvey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
triggerSurveyMutate({ ...strippedSurvey })
|
||||
.then(async (response) => {
|
||||
if (!response?.ok) {
|
||||
@@ -180,6 +209,9 @@ export default function SurveyMenuBar({
|
||||
variant="darkCTA"
|
||||
loading={isMutatingSurvey}
|
||||
onClick={async () => {
|
||||
if (!validateSurvey(localSurvey)) {
|
||||
return;
|
||||
}
|
||||
await triggerSurveyMutate({ ...localSurvey, status: "inProgress" });
|
||||
router.push(`/environments/${environmentId}/surveys/${localSurvey.id}/summary?success=true`);
|
||||
}}>
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
// extend this object in order to add more validation rules
|
||||
|
||||
import {
|
||||
MultipleChoiceMultiQuestion,
|
||||
MultipleChoiceSingleQuestion,
|
||||
Question,
|
||||
} from "@formbricks/types/questions";
|
||||
|
||||
const validationRules = {
|
||||
multipleChoiceMulti: (question: MultipleChoiceMultiQuestion) => {
|
||||
return !question.choices.some((element) => element.label.trim() === "");
|
||||
},
|
||||
multipleChoiceSingle: (question: MultipleChoiceSingleQuestion) => {
|
||||
return !question.choices.some((element) => element.label.trim() === "");
|
||||
},
|
||||
defaultValidation: (question: Question) => {
|
||||
return question.headline.trim() !== "";
|
||||
},
|
||||
};
|
||||
|
||||
const validateQuestion = (question) => {
|
||||
const specificValidation = validationRules[question.type];
|
||||
const defaultValidation = validationRules.defaultValidation;
|
||||
|
||||
const specificValidationResult = specificValidation ? specificValidation(question) : true;
|
||||
const defaultValidationResult = defaultValidation(question);
|
||||
|
||||
// Return true only if both specific and default validation pass
|
||||
return specificValidationResult && defaultValidationResult;
|
||||
};
|
||||
|
||||
export { validateQuestion };
|
||||
@@ -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 (
|
||||
|
||||
@@ -45,6 +45,7 @@ export async function POST(request: Request): Promise<NextResponse> {
|
||||
let response: TResponse;
|
||||
try {
|
||||
const meta = {
|
||||
url: responseInput?.meta?.url ?? "",
|
||||
userAgent: {
|
||||
browser: agent?.browser.name,
|
||||
device: agent?.device.type,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { getSurveys } from "@/app/api/v1/js/surveys";
|
||||
import { responses } from "@/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/lib/api/validator";
|
||||
import { getActionClasses } from "@formbricks/lib/services/actionClass";
|
||||
import { getEnvironment } from "@formbricks/lib/services/environment";
|
||||
import { createPerson, getPerson } from "@formbricks/lib/services/person";
|
||||
import { getProductByEnvironmentId } from "@formbricks/lib/services/product";
|
||||
import { createSession, extendSession, getSession } from "@formbricks/lib/services/session";
|
||||
@@ -31,6 +32,16 @@ export async function POST(req: Request): Promise<NextResponse> {
|
||||
|
||||
const { environmentId, personId, sessionId } = inputValidation.data;
|
||||
|
||||
// check if environment exists
|
||||
const environment = await getEnvironment(environmentId);
|
||||
if (!environment) {
|
||||
return responses.badRequestResponse(
|
||||
"Environment does not exist",
|
||||
{ environmentId: "Environment with this ID does not exist" },
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
if (!personId) {
|
||||
// create a new person
|
||||
const person = await createPerson(environmentId);
|
||||
|
||||
@@ -18,6 +18,9 @@ export async function GET(_: Request, { params }: { params: { webhookId: string
|
||||
if (!webhook) {
|
||||
return responses.notFoundResponse("Webhook", params.webhookId);
|
||||
}
|
||||
if (webhook.environmentId !== apiKeyData.environmentId) {
|
||||
return responses.unauthorizedResponse();
|
||||
}
|
||||
return responses.successResponse(webhook);
|
||||
}
|
||||
|
||||
@@ -31,7 +34,16 @@ export async function DELETE(_: Request, { params }: { params: { webhookId: stri
|
||||
return responses.notAuthenticatedResponse();
|
||||
}
|
||||
|
||||
// add webhook to database
|
||||
// check if webhook exists
|
||||
const webhook = await getWebhook(params.webhookId);
|
||||
if (!webhook) {
|
||||
return responses.notFoundResponse("Webhook", params.webhookId);
|
||||
}
|
||||
if (webhook.environmentId !== apiKeyData.environmentId) {
|
||||
return responses.unauthorizedResponse();
|
||||
}
|
||||
|
||||
// delete webhook from database
|
||||
try {
|
||||
const webhook = await deleteWebhook(params.webhookId);
|
||||
return responses.successResponse(webhook);
|
||||
|
||||
@@ -11,7 +11,7 @@ import { cn } from "@formbricks/lib/cn";
|
||||
import { Confetti } from "@formbricks/ui";
|
||||
import { ArrowPathIcon } from "@heroicons/react/24/solid";
|
||||
import type { Survey } from "@formbricks/types/surveys";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
type EnhancedSurvey = Survey & {
|
||||
brandColor: string;
|
||||
@@ -34,10 +34,22 @@ 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);
|
||||
|
||||
// Not in an iframe, enable autofocus on input fields.
|
||||
useEffect(() => {
|
||||
if (window.self === window.top) {
|
||||
setAutofocus(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Scroll to top when the currentQuestion changes
|
||||
useEffect(() => {
|
||||
@@ -90,6 +102,10 @@ export default function LinkSurvey({ survey }: LinkSurveyProps) {
|
||||
brandColor={survey.brandColor}
|
||||
lastQuestion={lastQuestion}
|
||||
onSubmit={submitResponse}
|
||||
storedResponseValue={storedResponseValue}
|
||||
goToNextQuestion={goToNextQuestion}
|
||||
goToPreviousQuestion={showBackButton ? goToPreviousQuestion : undefined}
|
||||
autoFocus={autoFocus}
|
||||
/>
|
||||
)}
|
||||
</ContentWrapper>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export const TiredFace: React.FC<React.SVGProps<SVGCircleElement>> = (props) => {
|
||||
return (
|
||||
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg">
|
||||
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg" width={36} height={36}>
|
||||
<g id="line">
|
||||
<circle
|
||||
cx="36"
|
||||
@@ -61,7 +61,7 @@ export const TiredFace: React.FC<React.SVGProps<SVGCircleElement>> = (props) =>
|
||||
|
||||
export const WearyFace: React.FC<React.SVGProps<SVGCircleElement>> = (props) => {
|
||||
return (
|
||||
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg">
|
||||
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg" width={36} height={36}>
|
||||
<g id="line">
|
||||
<circle
|
||||
cx="36"
|
||||
@@ -122,7 +122,7 @@ export const WearyFace: React.FC<React.SVGProps<SVGCircleElement>> = (props) =>
|
||||
|
||||
export const PerseveringFace: React.FC<React.SVGProps<SVGCircleElement>> = (props) => {
|
||||
return (
|
||||
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg">
|
||||
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg" width={36} height={36}>
|
||||
<g id="line">
|
||||
<circle
|
||||
cx="36"
|
||||
@@ -192,7 +192,7 @@ export const PerseveringFace: React.FC<React.SVGProps<SVGCircleElement>> = (prop
|
||||
|
||||
export const FrowningFace: React.FC<React.SVGProps<SVGCircleElement>> = (props) => {
|
||||
return (
|
||||
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg">
|
||||
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg" width={36} height={36}>
|
||||
<g id="line">
|
||||
<circle
|
||||
cx="36"
|
||||
@@ -222,7 +222,7 @@ export const FrowningFace: React.FC<React.SVGProps<SVGCircleElement>> = (props)
|
||||
|
||||
export const ConfusedFace: React.FC<React.SVGProps<SVGCircleElement>> = (props) => {
|
||||
return (
|
||||
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg">
|
||||
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg" width={36} height={36}>
|
||||
<g id="line">
|
||||
<circle
|
||||
cx="36"
|
||||
@@ -251,7 +251,7 @@ export const ConfusedFace: React.FC<React.SVGProps<SVGCircleElement>> = (props)
|
||||
|
||||
export const NeutralFace: React.FC<React.SVGProps<SVGCircleElement>> = (props) => {
|
||||
return (
|
||||
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg">
|
||||
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg" width={36} height={36}>
|
||||
<g id="line">
|
||||
<circle
|
||||
cx="36"
|
||||
@@ -284,7 +284,7 @@ export const NeutralFace: React.FC<React.SVGProps<SVGCircleElement>> = (props) =
|
||||
|
||||
export const SlightlySmilingFace: React.FC<React.SVGProps<SVGCircleElement>> = (props) => {
|
||||
return (
|
||||
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg">
|
||||
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg" width={36} height={36}>
|
||||
<g id="line">
|
||||
<circle
|
||||
cx="36"
|
||||
@@ -314,7 +314,7 @@ export const SlightlySmilingFace: React.FC<React.SVGProps<SVGCircleElement>> = (
|
||||
|
||||
export const SmilingFaceWithSmilingEyes: React.FC<React.SVGProps<SVGCircleElement>> = (props) => {
|
||||
return (
|
||||
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg">
|
||||
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg" width={36} height={36}>
|
||||
<g id="line">
|
||||
<circle
|
||||
cx="36"
|
||||
@@ -358,7 +358,7 @@ export const SmilingFaceWithSmilingEyes: React.FC<React.SVGProps<SVGCircleElemen
|
||||
|
||||
export const GrinningFaceWithSmilingEyes: React.FC<React.SVGProps<SVGCircleElement>> = (props) => {
|
||||
return (
|
||||
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg">
|
||||
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg" width={36} height={36}>
|
||||
<g id="line">
|
||||
<circle
|
||||
cx="36"
|
||||
@@ -410,7 +410,7 @@ export const GrinningFaceWithSmilingEyes: React.FC<React.SVGProps<SVGCircleEleme
|
||||
|
||||
export const GrinningSquintingFace: React.FC<React.SVGProps<SVGCircleElement>> = (props) => {
|
||||
return (
|
||||
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg">
|
||||
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg" width={36} height={36}>
|
||||
<g id="line">
|
||||
<circle
|
||||
cx="36"
|
||||
|
||||
@@ -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,20 @@
|
||||
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;
|
||||
}
|
||||
|
||||
export default function OpenTextQuestion({
|
||||
@@ -16,51 +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 && !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 && !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,10 @@ interface QuestionConditionalProps {
|
||||
onSubmit: (data: { [x: string]: any }) => void;
|
||||
lastQuestion: boolean;
|
||||
brandColor: string;
|
||||
storedResponseValue: any;
|
||||
goToNextQuestion: (answer: any) => void;
|
||||
goToPreviousQuestion?: (answer: any) => void;
|
||||
autoFocus: boolean;
|
||||
}
|
||||
|
||||
export default function QuestionConditional({
|
||||
@@ -19,6 +23,10 @@ export default function QuestionConditional({
|
||||
onSubmit,
|
||||
lastQuestion,
|
||||
brandColor,
|
||||
storedResponseValue,
|
||||
goToNextQuestion,
|
||||
goToPreviousQuestion,
|
||||
autoFocus,
|
||||
}: QuestionConditionalProps) {
|
||||
return question.type === QuestionType.OpenText ? (
|
||||
<OpenTextQuestion
|
||||
@@ -26,6 +34,10 @@ export default function QuestionConditional({
|
||||
onSubmit={onSubmit}
|
||||
lastQuestion={lastQuestion}
|
||||
brandColor={brandColor}
|
||||
storedResponseValue={storedResponseValue}
|
||||
goToNextQuestion={goToNextQuestion}
|
||||
goToPreviousQuestion={goToPreviousQuestion}
|
||||
autoFocus={autoFocus}
|
||||
/>
|
||||
) : question.type === QuestionType.MultipleChoiceSingle ? (
|
||||
<MultipleChoiceSingleQuestion
|
||||
@@ -33,6 +45,9 @@ export default function QuestionConditional({
|
||||
onSubmit={onSubmit}
|
||||
lastQuestion={lastQuestion}
|
||||
brandColor={brandColor}
|
||||
storedResponseValue={storedResponseValue}
|
||||
goToNextQuestion={goToNextQuestion}
|
||||
goToPreviousQuestion={goToPreviousQuestion}
|
||||
/>
|
||||
) : question.type === QuestionType.MultipleChoiceMulti ? (
|
||||
<MultipleChoiceMultiQuestion
|
||||
@@ -40,6 +55,9 @@ export default function QuestionConditional({
|
||||
onSubmit={onSubmit}
|
||||
lastQuestion={lastQuestion}
|
||||
brandColor={brandColor}
|
||||
storedResponseValue={storedResponseValue}
|
||||
goToNextQuestion={goToNextQuestion}
|
||||
goToPreviousQuestion={goToPreviousQuestion}
|
||||
/>
|
||||
) : question.type === QuestionType.NPS ? (
|
||||
<NPSQuestion
|
||||
@@ -47,6 +65,9 @@ export default function QuestionConditional({
|
||||
onSubmit={onSubmit}
|
||||
lastQuestion={lastQuestion}
|
||||
brandColor={brandColor}
|
||||
storedResponseValue={storedResponseValue}
|
||||
goToNextQuestion={goToNextQuestion}
|
||||
goToPreviousQuestion={goToPreviousQuestion}
|
||||
/>
|
||||
) : question.type === QuestionType.CTA ? (
|
||||
<CTAQuestion
|
||||
@@ -54,6 +75,9 @@ export default function QuestionConditional({
|
||||
onSubmit={onSubmit}
|
||||
lastQuestion={lastQuestion}
|
||||
brandColor={brandColor}
|
||||
storedResponseValue={storedResponseValue}
|
||||
goToNextQuestion={goToNextQuestion}
|
||||
goToPreviousQuestion={goToPreviousQuestion}
|
||||
/>
|
||||
) : question.type === QuestionType.Rating ? (
|
||||
<RatingQuestion
|
||||
@@ -61,6 +85,9 @@ export default function QuestionConditional({
|
||||
onSubmit={onSubmit}
|
||||
lastQuestion={lastQuestion}
|
||||
brandColor={brandColor}
|
||||
storedResponseValue={storedResponseValue}
|
||||
goToNextQuestion={goToNextQuestion}
|
||||
goToPreviousQuestion={goToPreviousQuestion}
|
||||
/>
|
||||
) : question.type === "consent" ? (
|
||||
<ConsentQuestion
|
||||
@@ -68,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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -64,6 +64,7 @@ export const env = createEnv({
|
||||
NEXT_PUBLIC_IS_FORMBRICKS_CLOUD: z.enum(["1", "0"]).optional(),
|
||||
NEXT_PUBLIC_POSTHOG_API_KEY: z.string().optional(),
|
||||
NEXT_PUBLIC_POSTHOG_API_HOST: z.string().optional(),
|
||||
NEXT_PUBLIC_SENTRY_DSN: z.string().optional(),
|
||||
},
|
||||
/*
|
||||
* Due to how Next.js bundles environment variables on Edge and Client,
|
||||
|
||||
@@ -101,6 +101,19 @@ const notAuthenticatedResponse = (cors: boolean = false) =>
|
||||
}
|
||||
);
|
||||
|
||||
const unauthorizedResponse = (cors: boolean = false) =>
|
||||
NextResponse.json(
|
||||
{
|
||||
code: "unauthorized",
|
||||
message: "You are not authorized to access this resource",
|
||||
details: {},
|
||||
} as ApiErrorResponse,
|
||||
{
|
||||
status: 401,
|
||||
...(cors && { headers: corsHeaders }),
|
||||
}
|
||||
);
|
||||
|
||||
const successResponse = (data: Object, cors: boolean = false) =>
|
||||
NextResponse.json(
|
||||
{
|
||||
@@ -131,6 +144,7 @@ export const responses = {
|
||||
missingFieldResponse,
|
||||
methodNotAllowedResponse,
|
||||
notAuthenticatedResponse,
|
||||
unauthorizedResponse,
|
||||
notFoundResponse,
|
||||
successResponse,
|
||||
};
|
||||
|
||||
@@ -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
|
||||
@@ -108,7 +132,11 @@ export const useLinkSurveyUtils = (survey: Survey) => {
|
||||
personId: personId,
|
||||
finished,
|
||||
data,
|
||||
meta: {
|
||||
url: window.location.href,
|
||||
},
|
||||
};
|
||||
|
||||
if (!responseId && !isPreview) {
|
||||
const response = await createResponse(
|
||||
responseRequest,
|
||||
@@ -118,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);
|
||||
@@ -133,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);
|
||||
}
|
||||
@@ -180,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,
|
||||
@@ -191,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 {
|
||||
@@ -284,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;
|
||||
|
||||
@@ -12,7 +12,7 @@ export const getQuestionResponseMapping = (
|
||||
|
||||
questionResponseMapping.push({
|
||||
question: question.headline,
|
||||
answer: answer.toString(),
|
||||
answer: typeof answer !== "undefined" ? answer.toString() : "",
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { withSentryConfig } from "@sentry/nextjs";
|
||||
import "./env.mjs";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
|
||||
@@ -65,4 +66,39 @@ const nextConfig = {
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
const sentryOptions = {
|
||||
// For all available options, see:
|
||||
// https://github.com/getsentry/sentry-webpack-plugin#options
|
||||
|
||||
// Suppresses source map uploading logs during build
|
||||
silent: true,
|
||||
|
||||
org: "formbricks",
|
||||
project: "formbricks-cloud",
|
||||
};
|
||||
|
||||
const sentryConfig = {
|
||||
// For all available options, see:
|
||||
// https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/
|
||||
|
||||
// Upload a larger set of source maps for prettier stack traces (increases build time)
|
||||
widenClientFileUpload: true,
|
||||
|
||||
// Transpiles SDK to be compatible with IE11 (increases bundle size)
|
||||
transpileClientSDK: true,
|
||||
|
||||
// Routes browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers (increases server load)
|
||||
tunnelRoute: "/monitoring",
|
||||
|
||||
// Hides source maps from generated client bundles
|
||||
hideSourceMaps: true,
|
||||
|
||||
// Automatically tree-shake Sentry logger statements to reduce bundle size
|
||||
disableLogger: true,
|
||||
};
|
||||
|
||||
const exportConfig = process.env.NEXT_PUBLIC_SENTRY_DSN
|
||||
? withSentryConfig(nextConfig, sentryOptions, sentryConfig)
|
||||
: nextConfig;
|
||||
|
||||
export default exportConfig;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@formbricks/web",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"clean": "rimraf .turbo node_modules .next",
|
||||
@@ -19,22 +19,23 @@
|
||||
"@formbricks/lib": "workspace:*",
|
||||
"@formbricks/types": "workspace:*",
|
||||
"@formbricks/ui": "workspace:*",
|
||||
"@headlessui/react": "^1.7.15",
|
||||
"@headlessui/react": "^1.7.16",
|
||||
"@heroicons/react": "^2.0.18",
|
||||
"@json2csv/node": "^7.0.1",
|
||||
"@paralleldrive/cuid2": "^2.2.1",
|
||||
"@radix-ui/react-collapsible": "^1.0.3",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.5",
|
||||
"@sentry/nextjs": "^7.60.1",
|
||||
"@t3-oss/env-nextjs": "^0.6.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"eslint-config-next": "^13.4.10",
|
||||
"eslint-config-next": "^13.4.12",
|
||||
"jsonwebtoken": "^9.0.1",
|
||||
"lodash": "^4.17.21",
|
||||
"lucide-react": "^0.260.0",
|
||||
"lucide-react": "^0.263.1",
|
||||
"next": "13.4.10",
|
||||
"next-auth": "^4.22.1",
|
||||
"nodemailer": "^6.9.3",
|
||||
"posthog-js": "^1.71.0",
|
||||
"next-auth": "^4.22.3",
|
||||
"nodemailer": "^6.9.4",
|
||||
"posthog-js": "^1.75.2",
|
||||
"prismjs": "^1.29.0",
|
||||
"react": "18.2.0",
|
||||
"react-beautiful-dnd": "^13.1.1",
|
||||
@@ -48,7 +49,7 @@
|
||||
"devDependencies": {
|
||||
"@formbricks/tsconfig": "workspace:*",
|
||||
"@types/bcryptjs": "^2.4.2",
|
||||
"@types/lodash": "^4.14.195",
|
||||
"@types/lodash": "^4.14.196",
|
||||
"@types/markdown-it": "^12.2.3",
|
||||
"eslint-config-formbricks": "workspace:*"
|
||||
}
|
||||
|
||||
@@ -22,12 +22,16 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
|
||||
return res.status(400).json({ message: "Missing personId" });
|
||||
}
|
||||
|
||||
const session = await createSession(personId);
|
||||
const settings = await getSettings(environmentId, personId);
|
||||
try {
|
||||
const session = await createSession(personId);
|
||||
const settings = await getSettings(environmentId, personId);
|
||||
|
||||
captureTelemetry("session created");
|
||||
captureTelemetry("session created");
|
||||
|
||||
return res.json({ session, settings });
|
||||
return res.json({ session, settings });
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// Unknown HTTP Method
|
||||
|
||||
30
apps/web/sentry.client.config.ts
Normal file
30
apps/web/sentry.client.config.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
// This file configures the initialization of Sentry on the client.
|
||||
// The config you add here will be used whenever a users loads a page in their browser.
|
||||
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
|
||||
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
|
||||
Sentry.init({
|
||||
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||
|
||||
// Adjust this value in production, or use tracesSampler for greater control
|
||||
tracesSampleRate: 1,
|
||||
|
||||
// Setting this option to true will print useful information to the console while you're setting up Sentry.
|
||||
debug: false,
|
||||
|
||||
replaysOnErrorSampleRate: 1.0,
|
||||
|
||||
// This sets the sample rate to be 10%. You may want this to be 100% while
|
||||
// in development and sample at a lower rate in production
|
||||
replaysSessionSampleRate: 0.1,
|
||||
|
||||
// You can remove this option if you're not planning to use the Sentry Session Replay feature:
|
||||
integrations: [
|
||||
new Sentry.Replay({
|
||||
// Additional Replay configuration goes in here, for example:
|
||||
maskAllText: true,
|
||||
blockAllMedia: true,
|
||||
}),
|
||||
],
|
||||
});
|
||||
16
apps/web/sentry.edge.config.ts
Normal file
16
apps/web/sentry.edge.config.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
// This file configures the initialization of Sentry for edge features (middleware, edge routes, and so on).
|
||||
// The config you add here will be used whenever one of the edge features is loaded.
|
||||
// Note that this config is unrelated to the Vercel Edge Runtime and is also required when running locally.
|
||||
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
|
||||
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
|
||||
Sentry.init({
|
||||
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||
|
||||
// Adjust this value in production, or use tracesSampler for greater control
|
||||
tracesSampleRate: 1,
|
||||
|
||||
// Setting this option to true will print useful information to the console while you're setting up Sentry.
|
||||
debug: false,
|
||||
});
|
||||
15
apps/web/sentry.server.config.ts
Normal file
15
apps/web/sentry.server.config.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
// This file configures the initialization of Sentry on the server.
|
||||
// The config you add here will be used whenever the server handles a request.
|
||||
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
|
||||
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
|
||||
Sentry.init({
|
||||
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||
|
||||
// Adjust this value in production, or use tracesSampler for greater control
|
||||
tracesSampleRate: 1,
|
||||
|
||||
// Setting this option to true will print useful information to the console while you're setting up Sentry.
|
||||
debug: false,
|
||||
});
|
||||
@@ -1,45 +1,29 @@
|
||||
# Formbricks Quickstart Using Docker
|
||||
# Self Host Formbricks Production Instance
|
||||
|
||||
Follow the instructions below to quickly get Formbricks running on your system with Docker. This guide is designed for most users who want a straightforward setup process.
|
||||
Follow this guide to get your Formbricks instance up and running with a Postgres DB and SSL certificate using a single script:
|
||||
|
||||
1. **Create a New Directory for Formbricks**
|
||||
## Requirements
|
||||
|
||||
Open a terminal and create a new directory for Formbricks, then navigate into this new directory:
|
||||
Before you proceed, make sure you have the following:
|
||||
|
||||
\```bash
|
||||
mkdir formbricks-quickstart && cd formbricks-quickstart
|
||||
\```
|
||||
- A Linux Ubuntu Virtual Machine deployed with SSH access.
|
||||
|
||||
2. **Download the Docker-Compose File**
|
||||
- An A record set up to connect a custom domain to your instance. Formbricks will automatically create an SSL certificate for your domain using Let's Encrypt.
|
||||
|
||||
Download the docker-compose file directly from the Formbricks repository:
|
||||
## Single Command Setup
|
||||
|
||||
\```bash
|
||||
curl -o docker-compose.yml https://raw.githubusercontent.com/formbricks/formbricks/docker/main/docker-compose.yml
|
||||
\```
|
||||
Copy and paste the following command into your terminal:
|
||||
|
||||
3. **Generate NextAuth Secret**
|
||||
```bash
|
||||
/bin/sh -c "$(curl -fsSL https://raw.githubusercontent.com/formbricks/formbricks/main/docker/production.sh)"
|
||||
```
|
||||
|
||||
Next, you need to generate a NextAuth secret. This will be used for session signing and encryption. The `sed` command below generates a random string using `openssl`, then replaces the `NEXTAUTH_SECRET:` placeholder in the `docker-compose.yml` file with this generated secret:
|
||||
The script will prompt you for the following information:
|
||||
|
||||
\```bash
|
||||
sed -i "/NEXTAUTH_SECRET:$/s/NEXTAUTH_SECRET:.\*/NEXTAUTH_SECRET: $(openssl rand -base64 32)/" docker-compose.yml
|
||||
\```
|
||||
1. **Overwriting Docker GPG Keys**: If Docker GPG keys already exist, the script will ask if you want to overwrite them.
|
||||
|
||||
4. **Start the Docker Setup**
|
||||
2. **Email Address**: Provide your email address for SSL certificate registration with Let's Encrypt.
|
||||
|
||||
You're now ready to start the Formbricks Docker setup. The following command will start Formbricks together with a postgreSQL database using Docker Compose:
|
||||
3. **Domain Name**: Enter the domain name that Traefik will use to create the SSL certificate and forward requests to Formbricks.
|
||||
|
||||
\```bash
|
||||
docker compose up -d
|
||||
\```
|
||||
|
||||
The `-d` flag will run the containers in detached mode, meaning they'll run in the background.
|
||||
|
||||
5. **Visit Formbricks in Your Browser**
|
||||
|
||||
After starting the Docker setup, visit http://localhost:3000 in your browser to interact with the Formbricks application. The first time you access this page, you'll be greeted by a setup wizard. Follow the prompts to define your first user and get started.
|
||||
|
||||
Enjoy using Formbricks!
|
||||
|
||||
Note: For detailed documentation of local setup, take a look at our [self hosting docs](https://formbricks.com/docs/self-hosting/deployment)
|
||||
That's it! After running the command and providing the required information, visit the domain name you entered, and you should see the Formbricks home wizard!
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
version: "3.3"
|
||||
x-environment: &environment
|
||||
environment:
|
||||
########################################################################
|
||||
# ------------ MANDATORY (CHANGE ACCORDING TO YOUR SETUP) ------------#
|
||||
########################################################################
|
||||
|
||||
# PostgreSQL DB for Formbricks to connect to
|
||||
DATABASE_URL: "postgresql://postgres:postgres@postgres:5432/formbricks?schema=public"
|
||||
|
||||
@@ -18,18 +14,21 @@ x-environment: &environment
|
||||
# @see: https://next-auth.js.org/configuration/options#nextauth_secret
|
||||
# You can use: `openssl rand -base64 32` to generate one
|
||||
NEXTAUTH_SECRET:
|
||||
|
||||
# Set this to your public-facing URL, e.g., https://example.com
|
||||
# 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
|
||||
|
||||
182
docker/production.sh
Normal file
182
docker/production.sh
Normal file
@@ -0,0 +1,182 @@
|
||||
#!/bin/env bash
|
||||
|
||||
set -e
|
||||
ubuntu_version=$(lsb_release -a 2>/dev/null | grep -v "No LSB modules are available." | grep "Description:" | awk -F "Description:\t" '{print $2}')
|
||||
|
||||
# Friendly welcome
|
||||
echo "🧱 Welcome to the Formbricks single instance installer"
|
||||
echo ""
|
||||
echo "🛸 Fasten your seatbelts! We're setting up your Formbricks environment on your $ubuntu_version server."
|
||||
echo ""
|
||||
|
||||
# Remove any old Docker installations, without stopping the script if they're not found
|
||||
echo "🧹 Time to sweep away any old Docker installations."
|
||||
sudo apt-get remove docker docker-engine docker.io containerd runc >/dev/null 2>&1 || true
|
||||
|
||||
# Update package list
|
||||
echo "🔄 Updating your package list."
|
||||
sudo apt-get update >/dev/null 2>&1
|
||||
|
||||
# Install dependencies
|
||||
echo "📦 Installing the necessary dependencies."
|
||||
sudo apt-get install -y \
|
||||
ca-certificates \
|
||||
curl \
|
||||
gnupg \
|
||||
lsb-release >/dev/null 2>&1
|
||||
|
||||
# Set up Docker's official GPG key & stable repository
|
||||
echo "🔑 Adding Docker's official GPG key and setting up the stable repository."
|
||||
sudo mkdir -m 0755 -p /etc/apt/keyrings >/dev/null 2>&1
|
||||
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg >/dev/null 2>&1
|
||||
echo \
|
||||
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \
|
||||
$(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null 2>&1
|
||||
|
||||
# Update package list again
|
||||
echo "🔄 Updating your package list again."
|
||||
sudo apt-get update >/dev/null 2>&1
|
||||
|
||||
# Install Docker
|
||||
echo "🐳 Installing Docker."
|
||||
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin >/dev/null 2>&1
|
||||
|
||||
# Test Docker installation
|
||||
echo "🚀 Testing your Docker installation."
|
||||
if docker --version >/dev/null 2>&1; then
|
||||
echo "🎉 Docker is installed!"
|
||||
else
|
||||
echo "❌ Docker is not installed. Please install Docker before proceeding."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Adding your user to the Docker group
|
||||
echo "🐳 Adding your user to the Docker group to avoid using sudo with docker commands."
|
||||
sudo groupadd docker >/dev/null 2>&1 || true
|
||||
sudo usermod -aG docker $USER >/dev/null 2>&1
|
||||
|
||||
echo "🎉 Hooray! Docker is all set and ready to go. You're now ready to run your Formbricks instance!"
|
||||
|
||||
# Installing Traefik
|
||||
echo "🚗 Installing Traefik..."
|
||||
mkdir -p formbricks && cd formbricks
|
||||
echo "📁 Created Formbricks Quickstart directory at ./formbricks."
|
||||
|
||||
# Ask the user for their email address
|
||||
echo "💡 Please enter your email address for the SSL certificate:"
|
||||
read email_address
|
||||
|
||||
cat <<EOT >traefik.yaml
|
||||
entryPoints:
|
||||
web:
|
||||
address: ":80"
|
||||
http:
|
||||
redirections:
|
||||
entryPoint:
|
||||
to: websecure
|
||||
scheme: https
|
||||
permanent: true
|
||||
websecure:
|
||||
address: ":443"
|
||||
http:
|
||||
tls:
|
||||
certResolver: default
|
||||
providers:
|
||||
docker:
|
||||
watch: true
|
||||
exposedByDefault: false
|
||||
certificatesResolvers:
|
||||
default:
|
||||
acme:
|
||||
email: $email_address
|
||||
storage: acme.json
|
||||
caServer: "https://acme-v01.api.letsencrypt.org/directory"
|
||||
tlsChallenge: {}
|
||||
EOT
|
||||
|
||||
echo "💡 Created traefik.yaml file with your provided email address."
|
||||
|
||||
touch acme.json
|
||||
chmod 600 acme.json
|
||||
echo "💡 Created acme.json file with correct permissions."
|
||||
|
||||
# Ask the user for their email address
|
||||
echo "🔗 Please enter your domain name for the SSL certificate (🚨 do NOT enter the protocol (http/https/etc)):"
|
||||
read domain_name
|
||||
|
||||
cat <<EOT >docker-compose.yml
|
||||
version: "3.3"
|
||||
x-environment: &environment
|
||||
environment:
|
||||
# PostgreSQL DB for Formbricks to connect to
|
||||
DATABASE_URL: "postgresql://postgres:postgres@postgres:5432/formbricks?schema=public"
|
||||
|
||||
# Uncomment to enable a dedicated connection pool for Prisma using Prisma Data Proxy
|
||||
# Cold boots will be faster and you'll be able to scale your DB independently of your app.
|
||||
# @see https://www.prisma.io/docs/data-platform/data-proxy/use-data-proxy
|
||||
# PRISMA_GENERATE_DATAPROXY: true
|
||||
PRISMA_GENERATE_DATAPROXY:
|
||||
|
||||
# NextJS Auth
|
||||
# @see: https://next-auth.js.org/configuration/options#nextauth_secret
|
||||
# You can use: $(openssl rand -base64 32) to generate one
|
||||
NEXTAUTH_SECRET:
|
||||
|
||||
# Set this to your public-facing URL, e.g., https://example.com
|
||||
# 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
|
||||
|
||||
formbricks:
|
||||
restart: always
|
||||
image: formbricks/formbricks:latest
|
||||
depends_on:
|
||||
- postgres
|
||||
labels:
|
||||
- "traefik.enable=true" # Enable Traefik for this service
|
||||
- "traefik.http.routers.formbricks.rule=Host(\`$domain_name\`)" # Replace your_domain_name with your actual domain or IP
|
||||
- "traefik.http.routers.formbricks.entrypoints=websecure" # Use the websecure entrypoint (port 443 with TLS)
|
||||
- "traefik.http.services.formbricks.loadbalancer.server.port=3000" # Forward traffic to Formbricks on port 3000
|
||||
<<: *environment
|
||||
|
||||
traefik:
|
||||
image: "traefik:v2.7"
|
||||
restart: always
|
||||
container_name: "traefik"
|
||||
depends_on:
|
||||
- formbricks
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- ./traefik.yaml:/traefik.yaml
|
||||
- ./acme.json:/acme.json
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
|
||||
volumes:
|
||||
postgres:
|
||||
driver: local
|
||||
EOT
|
||||
|
||||
echo "🚙 Updating NEXTAUTH_SECRET in the Formbricks container..."
|
||||
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
|
||||
|
||||
docker compose up -d
|
||||
|
||||
echo "🚨 Make sure you have set up the DNS records as well as inbound rules for the domain name and IP address of this instance."
|
||||
echo ""
|
||||
echo "🎉 All done! Check the status of Formbricks & Traefik with 'cd formbricks && sudo docker compose ps.'"
|
||||
@@ -16,7 +16,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@formbricks/database": "workspace:*",
|
||||
"next": "13.4.10",
|
||||
"stripe": "^12.13.0"
|
||||
"next": "13.4.12",
|
||||
"stripe": "^12.16.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,10 +8,10 @@
|
||||
"clean": "rimraf node_modules .turbo"
|
||||
},
|
||||
"devDependencies": {
|
||||
"eslint": "^8.45.0",
|
||||
"eslint-config-next": "^13.4.10",
|
||||
"eslint-config-prettier": "^8.8.0",
|
||||
"eslint-plugin-react": "7.32.2",
|
||||
"eslint": "^8.46.0",
|
||||
"eslint-config-next": "^13.4.12",
|
||||
"eslint-config-prettier": "^8.9.0",
|
||||
"eslint-plugin-react": "7.33.1",
|
||||
"eslint-config-turbo": "latest"
|
||||
}
|
||||
}
|
||||
|
||||
13
packages/js/CHANGELOG.md
Normal file
13
packages/js/CHANGELOG.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# @formbricks/js
|
||||
|
||||
## 1.0.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- a1b447ca: Increase z-index to 999999 to increase compatibility with more websites
|
||||
|
||||
## 1.0.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 3d0d633b: Fix new Session event not triggered every time a new session is created
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@formbricks/js",
|
||||
"license": "MIT",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.2",
|
||||
"description": "Formbricks-js allows you to connect your app to Formbricks, display surveys and trigger events.",
|
||||
"keywords": [
|
||||
"Formbricks",
|
||||
@@ -50,21 +50,21 @@
|
||||
"@formbricks/api": "workspace:*",
|
||||
"@formbricks/types": "workspace:*",
|
||||
"@types/jest": "^29.5.3",
|
||||
"@typescript-eslint/eslint-plugin": "^6.0.0",
|
||||
"@typescript-eslint/parser": "^6.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^6.2.0",
|
||||
"@typescript-eslint/parser": "^6.2.0",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"babel-jest": "^29.6.1",
|
||||
"babel-jest": "^29.6.2",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint-config-formbricks": "workspace:*",
|
||||
"eslint-config-preact": "^1.3.0",
|
||||
"isomorphic-fetch": "^3.0.0",
|
||||
"jest": "^29.6.1",
|
||||
"jest-environment-jsdom": "^29.6.1",
|
||||
"jest": "^29.6.2",
|
||||
"jest-environment-jsdom": "^29.6.2",
|
||||
"jest-fetch-mock": "^3.0.3",
|
||||
"jest-preset-preact": "^4.1.0",
|
||||
"microbundle": "^0.15.1",
|
||||
"preact": "10.16.0",
|
||||
"preact-cli": "^3.4.6",
|
||||
"preact-cli": "^3.5.0",
|
||||
"preact-render-to-string": "^6.2.0",
|
||||
"regenerator-runtime": "^0.13.11"
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { JSX } from "preact";
|
||||
|
||||
export const TiredFace: FunctionComponent<JSX.HTMLAttributes<SVGCircleElement>> = (props) => {
|
||||
return (
|
||||
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg">
|
||||
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg" width={36} height={36}>
|
||||
<g id="line">
|
||||
<circle
|
||||
cx="36"
|
||||
@@ -64,7 +64,7 @@ export const TiredFace: FunctionComponent<JSX.HTMLAttributes<SVGCircleElement>>
|
||||
|
||||
export const WearyFace: FunctionComponent<JSX.HTMLAttributes<SVGCircleElement>> = (props) => {
|
||||
return (
|
||||
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg">
|
||||
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg" width={36} height={36}>
|
||||
<g id="line">
|
||||
<circle
|
||||
cx="36"
|
||||
@@ -125,7 +125,7 @@ export const WearyFace: FunctionComponent<JSX.HTMLAttributes<SVGCircleElement>>
|
||||
|
||||
export const PerseveringFace: FunctionComponent<JSX.HTMLAttributes<SVGCircleElement>> = (props) => {
|
||||
return (
|
||||
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg">
|
||||
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg" width={36} height={36}>
|
||||
<g id="line">
|
||||
<circle
|
||||
cx="36"
|
||||
@@ -195,7 +195,7 @@ export const PerseveringFace: FunctionComponent<JSX.HTMLAttributes<SVGCircleElem
|
||||
|
||||
export const FrowningFace: FunctionComponent<JSX.HTMLAttributes<SVGCircleElement>> = (props) => {
|
||||
return (
|
||||
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg">
|
||||
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg" width={36} height={36}>
|
||||
<g id="line">
|
||||
<circle
|
||||
cx="36"
|
||||
@@ -225,7 +225,7 @@ export const FrowningFace: FunctionComponent<JSX.HTMLAttributes<SVGCircleElement
|
||||
|
||||
export const ConfusedFace: FunctionComponent<JSX.HTMLAttributes<SVGCircleElement>> = (props) => {
|
||||
return (
|
||||
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg">
|
||||
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg" width={36} height={36}>
|
||||
<g id="line">
|
||||
<circle
|
||||
cx="36"
|
||||
@@ -254,7 +254,7 @@ export const ConfusedFace: FunctionComponent<JSX.HTMLAttributes<SVGCircleElement
|
||||
|
||||
export const NeutralFace: FunctionComponent<JSX.HTMLAttributes<SVGCircleElement>> = (props) => {
|
||||
return (
|
||||
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg">
|
||||
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg" width={36} height={36}>
|
||||
<g id="line">
|
||||
<circle
|
||||
cx="36"
|
||||
@@ -287,7 +287,7 @@ export const NeutralFace: FunctionComponent<JSX.HTMLAttributes<SVGCircleElement>
|
||||
|
||||
export const SlightlySmilingFace: FunctionComponent<JSX.HTMLAttributes<SVGCircleElement>> = (props) => {
|
||||
return (
|
||||
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg">
|
||||
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg" width={36} height={36}>
|
||||
<g id="line">
|
||||
<circle
|
||||
cx="36"
|
||||
@@ -319,7 +319,7 @@ export const SmilingFaceWithSmilingEyes: FunctionComponent<JSX.HTMLAttributes<SV
|
||||
props
|
||||
) => {
|
||||
return (
|
||||
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg">
|
||||
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg" width={36} height={36}>
|
||||
<g id="line">
|
||||
<circle
|
||||
cx="36"
|
||||
@@ -365,7 +365,7 @@ export const GrinningFaceWithSmilingEyes: FunctionComponent<JSX.HTMLAttributes<S
|
||||
props
|
||||
) => {
|
||||
return (
|
||||
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg">
|
||||
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg" width={36} height={36}>
|
||||
<g id="line">
|
||||
<circle
|
||||
cx="36"
|
||||
@@ -417,7 +417,7 @@ export const GrinningFaceWithSmilingEyes: FunctionComponent<JSX.HTMLAttributes<S
|
||||
|
||||
export const GrinningSquintingFace: FunctionComponent<JSX.HTMLAttributes<SVGCircleElement>> = (props) => {
|
||||
return (
|
||||
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg">
|
||||
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg" width={36} height={36}>
|
||||
<g id="line">
|
||||
<circle
|
||||
cx="36"
|
||||
@@ -468,4 +468,6 @@ export const GrinningSquintingFace: FunctionComponent<JSX.HTMLAttributes<SVGCirc
|
||||
);
|
||||
};
|
||||
|
||||
export let icons = [<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg"></svg>];
|
||||
export let icons = [
|
||||
<svg viewBox="0 0 72 72" xmlns="http://www.w3.org/2000/svg" width={36} height={36}></svg>,
|
||||
];
|
||||
|
||||
@@ -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
|
||||
@@ -187,17 +232,24 @@ export default function SurveyView({ config, survey, close, errorHandler }: Surv
|
||||
personId: config.state.person.id,
|
||||
finished,
|
||||
data,
|
||||
meta: {
|
||||
url: window.location.href,
|
||||
},
|
||||
};
|
||||
if (!responseId) {
|
||||
const [response, _] = await Promise.all([
|
||||
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) {
|
||||
@@ -207,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();
|
||||
@@ -250,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}
|
||||
/>
|
||||
)
|
||||
)
|
||||
|
||||
@@ -35,12 +35,7 @@ const addSyncEventListener = (debug?: boolean): void => {
|
||||
}
|
||||
syncIntervalId = window.setInterval(async () => {
|
||||
logger.debug("Syncing.");
|
||||
const syncResult = await sync();
|
||||
if (syncResult.ok !== true) {
|
||||
return err(syncResult.error);
|
||||
}
|
||||
const state = syncResult.value;
|
||||
config.update({ state });
|
||||
await sync();
|
||||
}, updateInverval);
|
||||
// clear interval on page unload
|
||||
window.addEventListener("beforeunload", () => {
|
||||
@@ -95,21 +90,13 @@ export const initialize = async (
|
||||
if (isExpired(existingSession)) {
|
||||
logger.debug("Session expired. Resyncing.");
|
||||
|
||||
const syncResult = await sync();
|
||||
|
||||
// if create sync fails, clear config and start from scratch
|
||||
if (syncResult.ok !== true) {
|
||||
try {
|
||||
await sync();
|
||||
} catch (e) {
|
||||
logger.debug("Sync failed. Clearing config and starting from scratch.");
|
||||
await resetPerson();
|
||||
return await initialize(c);
|
||||
}
|
||||
|
||||
const state = syncResult.value;
|
||||
|
||||
config.update({ state });
|
||||
|
||||
const trackActionResult = await trackAction("New Session");
|
||||
|
||||
if (trackActionResult.ok !== true) return err(trackActionResult.error);
|
||||
} else {
|
||||
logger.debug("Session valid. Continuing.");
|
||||
// continue for now - next sync will check complete state
|
||||
@@ -120,19 +107,7 @@ export const initialize = async (
|
||||
config.update({ environmentId: c.environmentId, apiHost: c.apiHost, state: undefined });
|
||||
|
||||
logger.debug("Syncing.");
|
||||
const syncResult = await sync();
|
||||
|
||||
if (syncResult.ok !== true) {
|
||||
return err(syncResult.error);
|
||||
}
|
||||
|
||||
const state = syncResult.value;
|
||||
|
||||
config.update({ state });
|
||||
|
||||
const trackActionResult = await trackAction("New Session");
|
||||
|
||||
if (trackActionResult.ok !== true) return err(trackActionResult.error);
|
||||
await sync();
|
||||
}
|
||||
|
||||
logger.debug("Add session event listeners");
|
||||
|
||||
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`);
|
||||
};
|
||||
@@ -179,26 +179,12 @@ export const setPersonAttribute = async (
|
||||
export const resetPerson = async (): Promise<Result<void, NetworkError>> => {
|
||||
logger.debug("Resetting state & getting new state from backend");
|
||||
config.update({ state: undefined });
|
||||
const syncResult = await sync();
|
||||
|
||||
let error: NetworkError;
|
||||
|
||||
match(
|
||||
syncResult,
|
||||
(state) => {
|
||||
config.update({ state });
|
||||
},
|
||||
(err) => {
|
||||
// pass error to outer scope
|
||||
error = err;
|
||||
}
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return err(error);
|
||||
try {
|
||||
await sync();
|
||||
return okVoid();
|
||||
} catch (e) {
|
||||
return err(e);
|
||||
}
|
||||
|
||||
return okVoid();
|
||||
};
|
||||
|
||||
export const getPerson = (): TPerson => {
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { TJsState } from "@formbricks/types/v1/js";
|
||||
import { trackAction } from "./actions";
|
||||
import { Config } from "./config";
|
||||
import { NetworkError, Result, err, ok } from "./errors";
|
||||
import { Logger } from "./logger";
|
||||
|
||||
const logger = Logger.getInstance();
|
||||
const config = Config.getInstance();
|
||||
|
||||
export const sync = async (): Promise<Result<TJsState, NetworkError>> => {
|
||||
const syncWithBackend = async (): Promise<Result<TJsState, NetworkError>> => {
|
||||
const url = `${config.get().apiHost}/api/v1/js/sync`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
@@ -34,3 +33,20 @@ export const sync = async (): Promise<Result<TJsState, NetworkError>> => {
|
||||
|
||||
return ok((await response.json()).data as TJsState);
|
||||
};
|
||||
|
||||
export const sync = async (): Promise<void> => {
|
||||
const syncResult = await syncWithBackend();
|
||||
if (syncResult.ok !== true) {
|
||||
throw syncResult.error;
|
||||
}
|
||||
const state = syncResult.value;
|
||||
const oldState = config.get().state;
|
||||
config.update({ state });
|
||||
// if session is new, track action
|
||||
if (!oldState?.session || oldState.session.id !== state.session.id) {
|
||||
const trackActionResult = await trackAction("New Session");
|
||||
if (trackActionResult.ok !== true) {
|
||||
throw trackActionResult.error;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -36,18 +36,12 @@ export const closeSurvey = async (): Promise<void> => {
|
||||
document.getElementById(containerId).remove();
|
||||
addWidgetContainer();
|
||||
|
||||
const syncResult = await sync();
|
||||
|
||||
match(
|
||||
syncResult,
|
||||
(value) => {
|
||||
config.update({ state: value });
|
||||
surveyRunning = false;
|
||||
},
|
||||
(error) => {
|
||||
errorHandler.handle(error);
|
||||
}
|
||||
);
|
||||
try {
|
||||
await sync();
|
||||
surveyRunning = false;
|
||||
} catch (e) {
|
||||
errorHandler.handle(e);
|
||||
}
|
||||
};
|
||||
|
||||
export const addWidgetContainer = (): void => {
|
||||
|
||||
@@ -7,7 +7,11 @@ module.exports = {
|
||||
},
|
||||
content: ["./src/**/*.{tsx,ts,jsx,js}"],
|
||||
theme: {
|
||||
extend: {},
|
||||
extend: {
|
||||
zIndex: {
|
||||
'999999': '999999',
|
||||
}
|
||||
},
|
||||
},
|
||||
prefix: "fb-",
|
||||
plugins: [],
|
||||
|
||||
@@ -16,22 +16,12 @@ export const getWebhooks = async (environmentId: string): Promise<TWebhook[]> =>
|
||||
};
|
||||
|
||||
export const getWebhook = async (id: string): Promise<TWebhook | null> => {
|
||||
try {
|
||||
const webhook = await prisma.webhook.findUnique({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
});
|
||||
if (!webhook) {
|
||||
throw new ResourceNotFoundError("Webhook", id);
|
||||
}
|
||||
return webhook;
|
||||
} catch (error) {
|
||||
if (!(error instanceof ResourceNotFoundError)) {
|
||||
throw new DatabaseError(`Database error when fetching webhook with ID ${id}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const webhook = await prisma.webhook.findUnique({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
});
|
||||
return webhook;
|
||||
};
|
||||
|
||||
export const createWebhook = async (
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"@tailwindcss/forms": "^0.5.4",
|
||||
"@tailwindcss/typography": "^0.5.9",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"postcss": "^8.4.26",
|
||||
"postcss": "^8.4.27",
|
||||
"tailwindcss": "^3.3.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
"clean": "rimraf node_modules"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "20.4.2",
|
||||
"@types/react": "18.2.15",
|
||||
"@types/node": "20.4.5",
|
||||
"@types/react": "18.2.17",
|
||||
"@types/react-dom": "18.2.7",
|
||||
"typescript": "5.1.6"
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ const ZResponseNote = z.object({
|
||||
export type TResponseNote = z.infer<typeof ZResponseNote>;
|
||||
|
||||
export const ZResponseMeta = z.object({
|
||||
url: z.string(),
|
||||
userAgent: z.object({
|
||||
browser: z.string().optional(),
|
||||
os: z.string().optional(),
|
||||
@@ -67,6 +68,7 @@ export const ZResponseInput = z.object({
|
||||
data: ZResponseData,
|
||||
meta: z
|
||||
.object({
|
||||
url: z.string().optional(),
|
||||
userAgent: z
|
||||
.object({
|
||||
browser: z.string().optional(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface InputProps
|
||||
dangerouslySetInnerHTML?: {
|
||||
__html: string;
|
||||
};
|
||||
isInvalid?: boolean;
|
||||
}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(({ className, ...props }, ref) => {
|
||||
@@ -14,7 +15,8 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(({ className, ...pr
|
||||
<input
|
||||
className={cn(
|
||||
"focus:border-brand flex h-10 w-full rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm text-slate-800 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-500 dark:text-slate-300",
|
||||
className
|
||||
className,
|
||||
props.isInvalid && "border border-red-600 focus:border-red-600"
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
|
||||
@@ -16,19 +16,19 @@
|
||||
"@formbricks/tsconfig": "workspace:*",
|
||||
"concurrently": "^8.2.0",
|
||||
"eslint-config-formbricks": "workspace:*",
|
||||
"postcss": "^8.4.26",
|
||||
"postcss": "^8.4.27",
|
||||
"react": "18.2.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@formbricks/lib": "workspace:*",
|
||||
"@heroicons/react": "^2.0.18",
|
||||
"@lexical/code": "^0.11.2",
|
||||
"@lexical/link": "^0.11.2",
|
||||
"@lexical/list": "^0.11.2",
|
||||
"@lexical/markdown": "^0.11.2",
|
||||
"@lexical/react": "^0.11.2",
|
||||
"@lexical/rich-text": "^0.11.2",
|
||||
"@lexical/table": "^0.11.2",
|
||||
"@lexical/code": "^0.11.3",
|
||||
"@lexical/link": "^0.11.3",
|
||||
"@lexical/list": "^0.11.3",
|
||||
"@lexical/markdown": "^0.11.3",
|
||||
"@lexical/react": "^0.11.3",
|
||||
"@lexical/rich-text": "^0.11.3",
|
||||
"@lexical/table": "^0.11.3",
|
||||
"@radix-ui/react-checkbox": "^1.0.4",
|
||||
"@radix-ui/react-dialog": "^1.0.4",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.5",
|
||||
@@ -42,8 +42,8 @@
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.0.0",
|
||||
"cmdk": "^0.2.0",
|
||||
"lucide-react": "^0.260.0",
|
||||
"next": "13.4.10",
|
||||
"lucide-react": "^0.263.1",
|
||||
"next": "13.4.12",
|
||||
"react-colorful": "^5.6.1",
|
||||
"react-confetti": "^6.1.0",
|
||||
"react-day-picker": "^8.8.0",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user