mirror of
https://github.com/formbricks/formbricks.git
synced 2026-03-24 17:33:26 -05:00
Compare commits
17 Commits
action-tes
...
fix-docs-d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed946da338 | ||
|
|
95f8c2b49f | ||
|
|
5fb97008f3 | ||
|
|
555f5c3ea0 | ||
|
|
50407498ec | ||
|
|
556ee870b1 | ||
|
|
0cad316fca | ||
|
|
d98df5ed3b | ||
|
|
e718217ec4 | ||
|
|
d4dea7c2cc | ||
|
|
6bfd02794d | ||
|
|
b016d80cb2 | ||
|
|
f529f5eda3 | ||
|
|
3a1683eebd | ||
|
|
2ca38b1918 | ||
|
|
82504e54b1 | ||
|
|
61d8970420 |
13
.github/actions/cache-build-web/action.yml
vendored
13
.github/actions/cache-build-web/action.yml
vendored
@@ -1,5 +1,13 @@
|
||||
name: Build & Cache Web App
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
e2e_testing_mode:
|
||||
description: "Set E2E Testing Mode"
|
||||
required: false
|
||||
default: "0"
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
@@ -41,6 +49,11 @@ runs:
|
||||
run: cp .env.example .env
|
||||
shell: bash
|
||||
|
||||
- name: Add E2E Testing Mode
|
||||
run: |
|
||||
echo "E2E_TESTING=${{ inputs.e2e_testing_mode }}" >> $GITHUB_ENV
|
||||
shell: bash
|
||||
|
||||
- name: Generate Random ENCRYPTION_KEY
|
||||
run: |
|
||||
SECRET=$(openssl rand -hex 32)
|
||||
|
||||
2
.github/workflows/e2e.yml
vendored
2
.github/workflows/e2e.yml
vendored
@@ -13,6 +13,8 @@ jobs:
|
||||
|
||||
- name: Build & Cache Web Binaries
|
||||
uses: ./.github/actions/cache-build-web
|
||||
with:
|
||||
e2e_testing_mode: "1"
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
|
||||
@@ -13,12 +13,16 @@ export const SurveySwitch = ({ value, formbricks }: SurveySwitchProps) => {
|
||||
formbricks.logout();
|
||||
window.location.href = `/${v}`;
|
||||
}}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectTrigger className="w-[180px] px-4">
|
||||
<SelectValue placeholder="Theme" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="website">Website Surveys</SelectItem>
|
||||
<SelectItem value="app">App Surveys</SelectItem>
|
||||
<SelectItem value="website" className="h-10 px-4 hover:bg-slate-100">
|
||||
Website Surveys
|
||||
</SelectItem>
|
||||
<SelectItem value="app" className="hover:bg-slate-10 h-10 px-4">
|
||||
App Surveys
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
|
||||
@@ -13,10 +13,10 @@
|
||||
"dependencies": {
|
||||
"@formbricks/js": "workspace:*",
|
||||
"@formbricks/ui": "workspace:*",
|
||||
"lucide-react": "^0.373.0",
|
||||
"lucide-react": "^0.378.0",
|
||||
"next": "14.2.3",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0"
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"eslint-config-formbricks": "workspace:*",
|
||||
|
||||
@@ -115,7 +115,7 @@ export default function AppPage({}) {
|
||||
</div>
|
||||
|
||||
<div className="md:grid md:grid-cols-3">
|
||||
<div className="col-span-3 rounded-lg border border-slate-300 bg-slate-100 p-6 dark:border-slate-600 dark:bg-slate-800">
|
||||
<div className="col-span-3 self-start rounded-lg border border-slate-300 bg-slate-100 p-6 dark:border-slate-600 dark:bg-slate-800">
|
||||
<h3 className="text-lg font-semibold dark:text-white">
|
||||
Reset person / pull data from Formbricks app
|
||||
</h3>
|
||||
@@ -136,26 +136,6 @@ export default function AppPage({}) {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<div>
|
||||
<button
|
||||
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600"
|
||||
onClick={() => {
|
||||
formbricks.track("Code Action");
|
||||
}}>
|
||||
Code Action
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-700 dark:text-slate-300">
|
||||
This button sends a{" "}
|
||||
<a href="https://formbricks.com/docs/actions/code" className="underline" target="_blank">
|
||||
Code Action
|
||||
</a>{" "}
|
||||
to the Formbricks API called 'Code Action'. You will find it in the Actions Tab.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div>
|
||||
<button className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600">
|
||||
|
||||
@@ -115,7 +115,7 @@ export default function AppPage({}) {
|
||||
</div>
|
||||
|
||||
<div className="md:grid md:grid-cols-3">
|
||||
<div className="col-span-3 rounded-lg border border-slate-300 bg-slate-100 p-6 dark:border-slate-600 dark:bg-slate-800">
|
||||
<div className="col-span-3 self-start rounded-lg border border-slate-300 bg-slate-100 p-6 dark:border-slate-600 dark:bg-slate-800">
|
||||
<h3 className="text-lg font-semibold dark:text-white">
|
||||
Reset person / pull data from Formbricks app
|
||||
</h3>
|
||||
@@ -135,60 +135,6 @@ export default function AppPage({}) {
|
||||
try again.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="pt-6">
|
||||
<div>
|
||||
<button
|
||||
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600"
|
||||
onClick={() => {
|
||||
formbricks.track("New Session");
|
||||
}}>
|
||||
Track New Session
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-700 dark:text-slate-300">
|
||||
This button sends an Action to the Formbricks API called 'New Session'. You will
|
||||
find it in the Actions Tab.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-6">
|
||||
<div>
|
||||
<button
|
||||
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600"
|
||||
onClick={() => {
|
||||
formbricks.track("Exit Intent");
|
||||
}}>
|
||||
Track Exit Intent
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-700 dark:text-slate-300">
|
||||
This button sends an Action to the Formbricks API called 'Exit Intent'. You can also
|
||||
move your mouse to the top of the browser to trigger the exit intent.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-6">
|
||||
<div>
|
||||
<button
|
||||
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600"
|
||||
onClick={() => {
|
||||
formbricks.track("50% Scroll");
|
||||
}}>
|
||||
Track 50% Scroll
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-700 dark:text-slate-300">
|
||||
This button sends an Action to the Formbricks API called '50% Scroll'. You can also
|
||||
scroll down to trigger the 50% scroll.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
1
apps/docs/.gitignore
vendored
1
apps/docs/.gitignore
vendored
@@ -35,3 +35,4 @@ yarn-error.log*
|
||||
next-env.d.ts
|
||||
|
||||
public/sitemap*.xml
|
||||
public/robots.txt
|
||||
@@ -1,4 +1,5 @@
|
||||
import { MdxImage } from "@/components/MdxImage";
|
||||
import { ResponsiveVideo } from "@/components/ResponsiveVideo";
|
||||
|
||||
import GermansGpt from "./germans-gpt.webp";
|
||||
import Hni from "./hni.webp";
|
||||
@@ -18,13 +19,9 @@ export const metadata = {
|
||||
|
||||
Advanced Targeting allows you to show surveys to the right group of people. You can target surveys based on user attributes, user events, and more instead of spraying and praying. This helps you get more relevant feedback and make data-driven decisions. All of this without writing a single line of code.
|
||||
|
||||
<iframe
|
||||
width="700"
|
||||
height="450"
|
||||
src="https://www.youtube.com/embed/0BQp6N4cXzU?si=gyeEZRXZ6Kei1zzm"
|
||||
title="YouTube video player: Formbricks"
|
||||
frameborder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"></iframe>
|
||||
<ResponsiveVideo title="Formbricks Multi-language Surveys"
|
||||
src="https://www.youtube-nocookie.com/embed/0BQp6N4cXzU?si=KeBM7G7Ch1xtrsOm&controls=0" />
|
||||
|
||||
|
||||
## How to setup Advanced Targeting
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import { MdxImage } from "@/components/MdxImage";
|
||||
|
||||
import { Libraries } from "./components/Libraries";
|
||||
|
||||
import SetupChecklist from "./images/env-id.png";
|
||||
import ReactApp from "./images/react-in-app-survey-app-popup-form.webp";
|
||||
import WidgetConnected from "./images/widget-connected.webp";
|
||||
import WidgetNotConnected from "./images/widget-not-connected.webp";
|
||||
@@ -27,14 +26,7 @@ for something else, please [join our Discord!](https://formbricks.com/discord) a
|
||||
Before getting started, make sure you have:
|
||||
|
||||
1. A web application (behind your user authentication system) in your desired framework is set up and running.
|
||||
2. A Formbricks account with access to your environment ID and API host. You can find these in the **Setup Checklist** in the Settings:
|
||||
|
||||
<MdxImage
|
||||
src={SetupChecklist}
|
||||
alt="Step 2 - Setup Checklist"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
2. A Formbricks account with access to your environment ID and API host. You can find these in the **Setup Checklist** in the Settings.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -41,8 +41,8 @@ To run the Churn Survey in your app you want to proceed as follows:
|
||||
4. Prevent that churn!
|
||||
|
||||
<Note>
|
||||
## Formbricks Widget running? We assume that you have already installed the Formbricks Widget in your web
|
||||
app. It’s required to display messages and surveys in your app. If not, please follow the [Quick Start Guide
|
||||
## Formbricks Widget running?
|
||||
We assume that you have already installed the Formbricks Widget in your web app. It’s required to display messages and surveys in your app. If not, please follow the [Quick Start Guide
|
||||
(takes 15mins max.)](/app-surveys/quickstart)
|
||||
</Note>
|
||||
|
||||
|
||||
@@ -38,8 +38,8 @@ To run the Feature Chaser survey in your app you want to proceed as follows:
|
||||
2. Setup a user action to display survey at the right point in time
|
||||
|
||||
<Note>
|
||||
## Formbricks Widget running? We assume that you have already installed the Formbricks Widget in your web
|
||||
app. It’s required to display messages and surveys in your app. If not, please follow the [Quick Start Guide
|
||||
## Formbricks Widget running?
|
||||
We assume that you have already installed the Formbricks Widget in your web wapp. It’s required to display messages and surveys in your app. If not, please follow the [Quick Start Guide
|
||||
(takes 15mins max.)](/app-surveys/quickstart)
|
||||
</Note>
|
||||
|
||||
|
||||
@@ -38,8 +38,8 @@ To display the Trial Conversion Survey in your app you want to proceed as follow
|
||||
3. Print that 💸
|
||||
|
||||
<Note>
|
||||
## Formbricks Widget running? We assume that you have already installed the Formbricks Widget in your web
|
||||
app. It’s required to display messages and surveys in your app. If not, please follow the [Quick Start Guide
|
||||
## Formbricks Widget running?
|
||||
We assume that you have already installed the Formbricks Widget in your web app. It’s required to display messages and surveys in your app. If not, please follow the [Quick Start Guide
|
||||
(takes 15mins max.)](/app-surveys/quickstart)
|
||||
</Note>
|
||||
|
||||
|
||||
@@ -43,8 +43,8 @@ To display an Interview Prompt in your app you want to proceed as follows:
|
||||
3. That’s it! 🎉
|
||||
|
||||
<Note>
|
||||
## Formbricks Widget running? We assume that you have already installed the Formbricks Widget in your web
|
||||
app. It’s required to display messages and surveys in your app. If not, please follow the [Quick Start Guide
|
||||
## Formbricks Widget running?
|
||||
We assume that you have already installed the Formbricks Widget in your web app. It’s required to display messages and surveys in your app. If not, please follow the [Quick Start Guide
|
||||
(15mins).](/app-surveys/quickstart)
|
||||
</Note>
|
||||
|
||||
@@ -86,8 +86,9 @@ Save, and move over to the “Audience” tab.
|
||||
### 3. Pre-segment your audience (coming soon)
|
||||
|
||||
<Note>
|
||||
## Filter by attribute coming soon We're working on pre-segmenting users by attributes. We will update this
|
||||
manual in the next few days.
|
||||
## Filter by attribute coming soon.
|
||||
We're working on pre-segmenting users by attributes. We will update this
|
||||
manual in the next few days.
|
||||
</Note>
|
||||
|
||||
Once you clicked over to the “Audience” tab you can change the settings. In the **Who To Send** card, select “Filter audience by attribute”. This allows you to only show the prompt to a specific segment of your user base.
|
||||
@@ -161,9 +162,10 @@ Scroll down to “Recontact Options”. Here you have to choose the correct sett
|
||||
<MdxImage src={Publish} alt="Publish survey" quality="100" className="max-w-full rounded-lg sm:max-w-3xl" />
|
||||
|
||||
<Note>
|
||||
## Formbricks Widget running? You need to have the Formbricks Widget installed to display the Feedback Box
|
||||
in your app. Please follow [this tutorial (Step 4 onwards)](/app-surveys/quickstart)
|
||||
to install the widget.
|
||||
## Formbricks Widget running?
|
||||
You need to have the Formbricks Widget installed to display the Feedback Box
|
||||
in your app. Please follow [this tutorial (Step 4 onwards)](/app-surveys/quickstart)
|
||||
to install the widget.
|
||||
</Note>
|
||||
|
||||
###
|
||||
|
||||
@@ -37,8 +37,8 @@ To display the Product-Market Fit survey in your app you want to proceed as foll
|
||||
3. Setup the user action to display survey at good point in time
|
||||
|
||||
<Note>
|
||||
## Formbricks Widget running? We assume that you have already installed the Formbricks Widget in your web
|
||||
app. It’s required to display messages and surveys in your app. If not, please follow the [Quick Start Guide
|
||||
## Formbricks Widget running?
|
||||
We assume that you have already installed the Formbricks Widget in your web app. It’s required to display messages and surveys in your app. If not, please follow the [Quick Start Guide
|
||||
(15mins).](/app-surveys/quickstart)
|
||||
</Note>
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { MdxImage } from "@/components/MdxImage";
|
||||
import FAQ from "./components/FAQ";
|
||||
|
||||
import GitpodAuth from "./images/gitpod/auth.webp";
|
||||
import GitpodNewWorkspace from "./images/gitpod/new-workspace.webp";
|
||||
@@ -470,10 +469,3 @@ This happens when you're using the Demo App and delete the Person within the For
|
||||
|
||||
<MdxImage src={Logout} alt="Logout Person" quality="100" className="max-w-full rounded-lg sm:max-w-3xl" />
|
||||
|
||||
---
|
||||
|
||||
# Frequently Asked Questions
|
||||
|
||||
Here you'll find help with frequently recurring problems. If you can't find an answer to your question, please join our [Discord server](https://formbricks.com/discord).
|
||||
|
||||
<FAQ />
|
||||
|
||||
@@ -59,6 +59,6 @@ That’s it! Your webhooks will not start receiving data as soon as it arrives!
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
- API: Use our documented methods on Creation, List, & Deletion endpoints of the Webhook API mentioned here: https://documenter.getpostman.com/view/11026000/2sA3Bq5XEh#62e6ec65-021b-42a4-ac93-d1434b393c6c
|
||||
- API: Use our documented methods on Creation, List, & Deletion endpoints of the Webhook API mentioned in the [Postman Documenter](https://documenter.getpostman.com/view/11026000/2sA3Bq5XEh#62e6ec65-021b-42a4-ac93-d1434b393c6c)
|
||||
|
||||
---
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { MdxImage } from "@/components/MdxImage";
|
||||
|
||||
import { ResponsiveVideo } from "@/components/ResponsiveVideo";
|
||||
import AddLanguageInSurvey from "./add-language-in-survey.webp";
|
||||
import AddLanguages from "./add-languages.webp";
|
||||
import EditMultiLang from "./edit-multi-lang.webp";
|
||||
@@ -31,13 +32,9 @@ How to deliver a specific language depends on the survey type (app or link surve
|
||||
details on the Formbricks Cloud.
|
||||
</Note>
|
||||
|
||||
<iframe
|
||||
width="700"
|
||||
height="450"
|
||||
src="https://www.youtube.com/embed/Vol5zjYIoos?si=FOeDSqcy_OgtaUyM"
|
||||
title="YouTube video player: Formbricks"
|
||||
frameborder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"></iframe>
|
||||
|
||||
<ResponsiveVideo title="Formbricks Multi-language Surveys"
|
||||
src="https://www.youtube-nocookie.com/embed/Vol5zjYIoos?si=bdP2y3uk8-xR0uSD&controls=0" />
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -7,27 +7,25 @@ import Trigger from "./trigger.webp";
|
||||
import { MdxImage } from "@/components/MdxImage";
|
||||
|
||||
export const metadata = {
|
||||
title: "Inside Look: Formbricks In-Product Micro-Surveys",
|
||||
title: "Formbricks Components Overview",
|
||||
description:
|
||||
"Unlock the full potential of Formbricks: From intuitive form-building and event-based triggers to effortless integrations and deep analytics. Master the art of in-product surveys for your SaaS or digital platform.",
|
||||
"Formbricks is broadly composed of four components: An open source form builder, targeting & triggers, integrations and analytics & insights.",
|
||||
};
|
||||
|
||||
#### Introduction
|
||||
|
||||
# How Formbricks works
|
||||
|
||||
Formbricks is a powerful platform designed to help you create and manage in-product micro-surveys for SaaS and digital products. Here is an overview:
|
||||
Formbricks is broadly composed of four elements, which enable gathering, analyzing and reporting of experience data:
|
||||
|
||||
### Four components
|
||||
|
||||
1. **Form Builder**: Create and customize your surveys with a user-friendly, no-code interface.
|
||||
2. **Targeting & Triggers**: Define specific user segments and set event-based triggers to display your surveys to the right users at the right time.
|
||||
3. **Integration**: Seamlessly integrate Formbricks into your web or mobile application using the provided SDKs or the HTML snippet.
|
||||
1. **Survey builder**: Create and customize your surveys with a user-friendly, no-code interface.
|
||||
2. **Targeting & Triggers**: Define user segments based on attributes and set event-based triggers to display your surveys to the right users at the right time.
|
||||
3. **Integrations**: Seamlessly integrate Formbricks with your current stack using the provided SDKs, native integrations or open APIs.
|
||||
4. **Analytics & Insights**: Analyze user responses and gain actionable insights to make informed product decisions.
|
||||
|
||||
## Form Builder
|
||||
## Survey builder
|
||||
|
||||
The Form Builder is where you create and customize your micro-surveys. With its intuitive drag-and-drop interface, you can easily add different question types, set response options, and apply your branding to the survey forms. The Form Builder allows you to preview your survey in real-time, ensuring it looks and feels perfect for your users.
|
||||
The survey builder is where you create and customize your surveys. With its intuitive drag-and-drop interface, you can easily add different question types, set response options, and apply your branding to the survey forms. The survey builder allows you to preview your survey in real-time, ensuring it looks and feels perfect for your users.
|
||||
|
||||
<MdxImage
|
||||
src={FormBuilder}
|
||||
@@ -56,7 +54,7 @@ Formbricks offers fine-grained user targeting and event-based triggers to help y
|
||||
|
||||
## Integration
|
||||
|
||||
Integrating Formbricks into your web or mobile application is a breeze. With SDKs for popular web frameworks like React, and an HTML snippet for non-framework based websites, you can quickly add Formbricks to your project. The provided code snippets make it easy to initialize the Formbricks widget and configure it to communicate with your backend.
|
||||
Integrating Formbricks into your web is a breeze. With SDKs for popular web frameworks like React, and an HTML snippet for non-framework based websites, you can quickly add Formbricks to your project. The provided code snippets make it easy to initialize the Formbricks widget and configure it to communicate with your backend.
|
||||
|
||||
<MdxImage
|
||||
src={integrations}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 37 KiB |
@@ -4,9 +4,9 @@ import { HeroPattern } from "@/components/HeroPattern";
|
||||
import { GettingStarted } from "./components/GettingStarted";
|
||||
|
||||
export const metadata = {
|
||||
title: "Formbricks: Privacy-first Experience Management",
|
||||
title: "Formbricks: Open Source Experience Management",
|
||||
description:
|
||||
"Enhance your product with Formbricks – the leading open-source solution for in-product micro-surveys. Dive deep into user research, amplify product-market fit, and uncover the 'why' behind your analytics.",
|
||||
"Formbricks is a versatile open source survey platform with an Experience Management Suite built on top of it. Survey customers, users or employee at any points with a perfectly timed and targeted survey.",
|
||||
};
|
||||
|
||||
export const sections = [];
|
||||
@@ -15,26 +15,40 @@ export const sections = [];
|
||||
|
||||
# Formbricks – Open Source Experience Management
|
||||
|
||||
Welcome to Formbricks, your go-to solution for in-product micro-surveys that will supercharge your product experience! 🚀 {{ className: 'lead' }}
|
||||
Welcome to Formbricks! Formbricks is a versatile open source survey platform with an Experience Management Suite built on top of it. Survey customers, users or employee at any points with a perfectly timed and targeted survey. {{ className: 'lead' }}
|
||||
|
||||
<div className="mb-16 mt-6 flex gap-3" id="why-formbricks">
|
||||
<Button href="/app-surveys/quickstart" arrow="right" children="Quickstart" />
|
||||
</div>
|
||||
|
||||
## Why Formbricks? 🤔
|
||||
## Formbricks - The Open Source Survey Platform
|
||||
|
||||
Natively embed qualitative user research into your B2B SaaS. Leverage Best Practices for user discovery to increase Product-Market Fit. {{ className: 'lead' }}
|
||||
The foundation of Formbricks is an open source (AGPLv3) survey platform. Our objective is to built a survey tool which can be used to survey any stakeholder of an organisation (user, customer, employee, etc.) at any point on any platform.
|
||||
|
||||
- 🎯 **Tailor-made for SaaS & digital products**: Craft stunning, highly configurable surveys that enable better product decisions, deep user segmentation, and personalization.
|
||||
- 🌐 **Platform agnostic**: Seamlessly integrate Formbricks surveys into web, mobile, or desktop applications.
|
||||
- 📊 **Complete the analytics puzzle**: Answer the "why" behind your product analytics with insightful data analysis and visualization tools.
|
||||
- 🧪 **Smart triggering**: Show the right survey at the right time with event-based triggers for accurate research and well-defined priorities.
|
||||
- 🎉 **Open-source and self-hosted**: Enjoy full control over your data and infrastructure with our AGPL-licensed solution, and stay tuned for our upcoming cloud version!
|
||||
Today, you can already replace many of the existing surveying solutions with Formbricks:
|
||||
|
||||
- **Standalone surveys (share via link):** Replace Google Forms, Typeform or any other link survey tool [with Formbricks Form Builder](https://formbricks.com/open-source-form-builder). Use lots of question types and comprehensive customisations.
|
||||
- **Scalable website surveys:** Even if you have millions of website visitors, Formbricks lets you run well-timed and anonymously targeted [surveys on any public website.](https://formbricks.com/website-survey)
|
||||
- **Highly targeted app surveys:** Identify known users with Formbricks and enrich their profiles with attributes and specific actions. Build cohorts for [highly targeted in app surveys.](https://formbricks.com/in-app-survey)
|
||||
|
||||
The surveying platform is **largely free, also for commercial use.** As we further develop the product offering, all surveying capacity will move into the forever free Community Edition.
|
||||
|
||||
## Formbricks - The XM Solution
|
||||
|
||||
To fund the development of the most powerful and versatile surveying platform there is, we're building a commercial offering on top of the surveying platform: The Formbricks Experience Management (XM) Suite.
|
||||
|
||||
- **What is XM?** "Experience Management" describes the effort to gather, analyze and report data from any stakeholder (customers, users, employees, etc.) or an organisation to measure and then manage how their experience with the organisation is developing.
|
||||
|
||||
- **Why are we excited about XM?** Empowering companies, governments and nonprofit organisations to measure how customers, citizens, employees or visitors experience their products and services is a meaningful undertaking. Life is too short for poorly managed service offerings. Formbricks XM provides the data to make human-centric decisions at scale.
|
||||
|
||||
- **How does XM work on Formbricks?** Essentially, through reduction and contextualisation. In an app-like format, we strip away everything you don't need to measure a specific experience. We also provide meaningful context in matching templates, reports and best practices.
|
||||
|
||||
So far, we have spent most of our time and energy building out the open source survey platform which powers the above. Stick around to see how Formbricks XM Apps will empower everyone to think and work human-centric.
|
||||
|
||||
<div>
|
||||
<Button
|
||||
href="https://app.formbricks.com/"
|
||||
variant="text"
|
||||
variant="primary"
|
||||
arrow="right"
|
||||
target="_blank"
|
||||
children="Try Formbricks Cloud"
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
export const metadata = {
|
||||
title: "Formbricks vs. Generic Survey Tools: A Comparative Insight",
|
||||
description:
|
||||
"Discover how Formbricks excels as a specialized in-product micro-survey platform for SaaS. Get unmatched targeting, seamless integrations, and make informed decisions with our open-source advantage.",
|
||||
};
|
||||
|
||||
#### Introduction
|
||||
|
||||
# Why is Formbricks better?
|
||||
|
||||
Formbricks outshines other survey tools by specializing in in-product micro-surveys for SaaS and digital products. With Formbricks, you're better equipped to understand user behavior, improve your product, and make data-driven decisions. Let's see how Formbricks compares to generic survey tools.
|
||||
|
||||
| Feature | Generic Survey Tool | Formbricks In-Product Surveys |
|
||||
| --------------------------- | ------------------- | ----------------------------- |
|
||||
| Designed for SaaS | ❌ | ✅ |
|
||||
| In-product micro-surveys | ⚠️ (limited) | ✅ |
|
||||
| Customizable forms | ✅ | ✅ |
|
||||
| Fine-grained user targeting | ❌ | ✅ |
|
||||
| Seamless integrations | ⚠️ (limited) | ✅ |
|
||||
| Open-source | ❌ | ✅ |
|
||||
| Event-based triggers | ❌ | ✅ |
|
||||
| User segmentation | ❌ | ✅ |
|
||||
|
||||
With Formbricks, you're not just getting another survey tool, but an in-depth, data-driven solution tailor-made for digital products 🎉
|
||||
30
apps/docs/app/introduction/why-open-source/page.mdx
Normal file
30
apps/docs/app/introduction/why-open-source/page.mdx
Normal file
@@ -0,0 +1,30 @@
|
||||
export const metadata = {
|
||||
title: "Why Formbricks is Open Source",
|
||||
description:
|
||||
"Open source software beats proprietary software in every aspect - except for value capture. We're investing in growing the value creation of our open source platform because it directly translates into business with large organisations.",
|
||||
};
|
||||
|
||||
#### Introduction
|
||||
|
||||
# Why is Formbricks open source?
|
||||
|
||||
A lot has been written on why open source software beats proprietary software in all aspects - except for value capture for the company investing into its development. While this definitely poses a challenge for a profit-oriented organisation, it's also an interesting opportunity: Due to the open nature of our platform, it's usage is significantly higher. Capturing a small part of the value our platform generates translates into a decently-sized business.
|
||||
|
||||
|
||||
| Advantage | Open Source Software | Proprietary Software |
|
||||
|----------------------|----------------------------------------------------|----------------------------------------------------------|
|
||||
| **Data Privacy** | Self-host for maximum control over data | Dependent on thrid party data processor. |
|
||||
| **Cost** | Often free or significantly lower cost. | Typically requires a purchase or subscription. |
|
||||
| **Customizability** | Code can be modified to meet specific needs. | Limited customization, restricted to developer's features.|
|
||||
| **Security**| Frequent community reviews identify vulnerabilities quickly. | Security updates depend on vendor's schedule and interest.|
|
||||
| **Flexibility**| Supports a wide range of applications and integrations. | Designed for specific environments and integrations. |
|
||||
| **Community Support**| Large, active communities offer free support and resources. | Paid customer support with limited community help. |
|
||||
| **Innovation** | Fosters rapid innovation through community contributions. | Innovations depend on vendor's vision and development team.|
|
||||
| **Licensing** | Permissive licenses allow broad usage and modification. | Strict licensing with limited redistribution rights. |
|
||||
| **Independence** | Not dependent on a single vendor or developer. | Vendor lock-in can limit future choices. |
|
||||
| **Transparency** | Full visibility into the code base and development. | Closed-source, code is hidden from users. |
|
||||
| **Interoperability**| Supports open standards, ensuring interoperability. | Often requires additional software or plugins for compatibility. |
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { type Section } from "@/components/SectionProvider";
|
||||
import "@/styles/tailwind.css";
|
||||
import glob from "fast-glob";
|
||||
import { type Metadata } from "next";
|
||||
import { Jost } from "next/font/google";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
@@ -12,6 +13,8 @@ export const metadata: Metadata = {
|
||||
},
|
||||
};
|
||||
|
||||
const jost = Jost({ subsets: ["latin"] });
|
||||
|
||||
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
let pages = await glob("**/*.mdx", { cwd: "src/app" });
|
||||
let allSectionsEntries = (await Promise.all(
|
||||
@@ -24,7 +27,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
||||
|
||||
return (
|
||||
<html lang="en" className="h-full" suppressHydrationWarning>
|
||||
<body className="flex min-h-full bg-white antialiased dark:bg-zinc-900">
|
||||
<body className={`flex min-h-full bg-white antialiased dark:bg-zinc-900 ${jost.className}`}>
|
||||
<Providers>
|
||||
<div className="w-full">
|
||||
<Layout allSections={allSections}>{children}</Layout>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { MdxImage } from "@/components/MdxImage";
|
||||
import { ResponsiveVideo } from "@/components/ResponsiveVideo";
|
||||
|
||||
import ShareLink from "./share-link.webp";
|
||||
import ViewResponse from "./view-response.webp";
|
||||
@@ -18,13 +19,8 @@ Check out this video to learn more about source tracking in link surveys:
|
||||
|
||||
{/* Replace link below with our new link on Source Tracking */}
|
||||
|
||||
<iframe
|
||||
width="700"
|
||||
height="450"
|
||||
src="https://www.youtube.com/embed/CytWhuyEMVI?si=t-SFB2A1l1RZDdAC"
|
||||
title="YouTube video player: Formbricks"
|
||||
frameborder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"></iframe>
|
||||
<ResponsiveVideo title="Formbricks Source Tracking"
|
||||
src="https://www.youtube-nocookie.com/embed/CytWhuyEMVI?si=nxRrdMmlsQ5P6wwp&controls=0" />
|
||||
|
||||
## Purpose
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import { MdxImage } from "@/components/MdxImage";
|
||||
|
||||
import { Libraries } from "./components/Libraries";
|
||||
|
||||
import SetupChecklist from "./images/env-id.png";
|
||||
import ReactApp from "./images/react-in-app-survey-app-popup-form.webp";
|
||||
import WidgetConnected from "./images/widget-connected.webp";
|
||||
import WidgetNotConnected from "./images/widget-not-connected.webp";
|
||||
@@ -31,14 +30,7 @@ Detailed Website Survey SDK documentation can be found [here](/developer-docs/we
|
||||
Before getting started, make sure you have:
|
||||
|
||||
1. A **public-facing** web application in your desired framework is set up and running.
|
||||
2. A Formbricks account with access to your environment ID and API host. You can find these in the **Setup Checklist** in the Settings:
|
||||
|
||||
<MdxImage
|
||||
src={SetupChecklist}
|
||||
alt="Step 2 - Setup Checklist"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
2. A Formbricks account with access to your environment ID and API host. You can find these in the **Setup Checklist** in the Settings.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ function ArrowIcon(props: React.ComponentPropsWithoutRef<"svg">) {
|
||||
|
||||
const variantStyles = {
|
||||
primary:
|
||||
"rounded-full bg-slate-900 py-1 px-3 text-white hover:bg-slate-700 dark:bg-teal-400/10 dark:text-teal-400 dark:ring-1 dark:ring-inset dark:ring-teal-400/20 dark:hover:bg-teal-400/10 dark:hover:text-teal-300 dark:hover:ring-teal-300",
|
||||
"rounded-full bg-slate-900 py-1 px-3 text-white hover:text-white hover:bg-slate-700 dark:bg-teal-400/10 dark:text-teal-400 dark:ring-1 dark:ring-inset dark:ring-teal-400/20 dark:hover:bg-teal-400/10 dark:hover:text-teal-300 dark:hover:ring-teal-300",
|
||||
secondary:
|
||||
"rounded-full bg-slate-100 py-1 px-3 text-slate-900 hover:bg-slate-200 dark:bg-slate-800/40 dark:text-slate-400 dark:ring-1 dark:ring-inset dark:ring-slate-800 dark:hover:bg-slate-800 dark:hover:text-slate-300",
|
||||
filled:
|
||||
@@ -39,7 +39,7 @@ export function Button({ variant = "primary", className, children, arrow, ...pro
|
||||
"inline-flex gap-0.5 justify-center items-center overflow-hidden font-medium transition text-center",
|
||||
variantStyles[variant],
|
||||
className,
|
||||
"px-5 py-2.5 text-xs"
|
||||
"px-5 py-2.5 text-sm"
|
||||
);
|
||||
|
||||
let arrowIcon = (
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
import { navigation } from "@/lib/navigation";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { FaDiscord, FaGithub, FaXTwitter } from "react-icons/fa6";
|
||||
|
||||
import { Button } from "./Button";
|
||||
import { DiscordIcon } from "./icons/DiscordIcon";
|
||||
import { GithubIcon } from "./icons/GithubIcon";
|
||||
import { TwitterIcon } from "./icons/TwitterIcon";
|
||||
|
||||
function PageLink({
|
||||
label,
|
||||
@@ -98,13 +100,13 @@ function SmallPrint() {
|
||||
Formbricks GmbH © {currentYear}. All rights reserved.
|
||||
</p>
|
||||
<div className="flex gap-4">
|
||||
<SocialLink href="https://twitter.com/formbricks" icon={FaXTwitter}>
|
||||
<SocialLink href="https://twitter.com/formbricks" icon={TwitterIcon}>
|
||||
Follow us on Twitter
|
||||
</SocialLink>
|
||||
<SocialLink href="https://github.com/formbricks/formbricks" icon={FaGithub}>
|
||||
<SocialLink href="https://github.com/formbricks/formbricks" icon={GithubIcon}>
|
||||
Follow us on GitHub
|
||||
</SocialLink>
|
||||
<SocialLink href="https://formbricks.com/discord" icon={FaDiscord}>
|
||||
<SocialLink href="https://formbricks.com/discord" icon={DiscordIcon}>
|
||||
Join our Discord server
|
||||
</SocialLink>
|
||||
</div>
|
||||
|
||||
@@ -40,18 +40,6 @@ function useInitialValue<T>(value: T, condition = true) {
|
||||
return condition ? initialValue : value;
|
||||
}
|
||||
|
||||
function TopLevelNavItem({ href, children }: { href: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<li className="md:hidden">
|
||||
<Link
|
||||
href={href}
|
||||
className="block py-1 text-sm text-slate-600 transition hover:text-slate-900 dark:text-slate-400 dark:hover:text-white">
|
||||
{children}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function NavLink({
|
||||
href,
|
||||
children,
|
||||
@@ -72,7 +60,7 @@ function NavLink({
|
||||
isAnchorLink ? "pl-7" : "pl-4",
|
||||
active
|
||||
? "rounded-r-md bg-slate-100 text-slate-900 dark:bg-slate-800 dark:text-white"
|
||||
: "text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white"
|
||||
: "text-slate-600 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white"
|
||||
)}>
|
||||
<span className="flex w-full truncate">{children}</span>
|
||||
</Link>
|
||||
@@ -135,84 +123,71 @@ function ActivePageMarker({ group, pathname }: { group: NavGroup; pathname: stri
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function NavigationGroup({
|
||||
group,
|
||||
className,
|
||||
activeGroup,
|
||||
setActiveGroup,
|
||||
openGroups,
|
||||
setOpenGroups,
|
||||
}: {
|
||||
group: NavGroup;
|
||||
className?: string;
|
||||
activeGroup: NavGroup;
|
||||
setActiveGroup: (group: NavGroup) => void;
|
||||
activeGroup: NavGroup | null;
|
||||
setActiveGroup: (group: NavGroup | null) => void;
|
||||
openGroups: string[];
|
||||
setOpenGroups: (groups: string[]) => void;
|
||||
}) {
|
||||
// If this is the mobile navigation then we always render the initial
|
||||
// state, so that the state does not change during the close animation.
|
||||
// The state will still update when we re-open (re-render) the navigation.
|
||||
let isInsideMobileNavigation = useIsInsideMobileNavigation();
|
||||
let [pathname] = useInitialValue([usePathname()], isInsideMobileNavigation);
|
||||
const [activeParentTitle, setActiveParentTitle] = useState<string | undefined>(
|
||||
group.links.find((link) =>
|
||||
link.children
|
||||
? link.children.some((child) => pathname.startsWith(child.href))
|
||||
: pathname.startsWith(link.href || "")
|
||||
)?.title
|
||||
);
|
||||
const isInsideMobileNavigation = useIsInsideMobileNavigation();
|
||||
const pathname = usePathname();
|
||||
const isActiveGroup = activeGroup?.title === group.title;
|
||||
|
||||
const isActiveGroup = group.links.some((link) => {
|
||||
pathname.startsWith(link.href || "") ||
|
||||
(activeGroup &&
|
||||
link.title === activeGroup.title &&
|
||||
link.children &&
|
||||
link.children.some((child) => pathname.startsWith(child.href)));
|
||||
});
|
||||
const toggleParentTitle = (title: string) => {
|
||||
if (openGroups.includes(title)) {
|
||||
setOpenGroups(openGroups.filter((t) => t !== title));
|
||||
} else {
|
||||
setOpenGroups([...openGroups, title]);
|
||||
}
|
||||
setActiveGroup(group);
|
||||
};
|
||||
|
||||
const isParentOpen = (title: string) => openGroups.includes(title);
|
||||
|
||||
return (
|
||||
<li className={clsx("relative mt-6", className)}>
|
||||
<motion.h2 layout="position" className="text-xs font-semibold text-slate-900 dark:text-white">
|
||||
<motion.h2 layout="position" className="font-semibold text-slate-900 dark:text-white">
|
||||
{group.title}
|
||||
</motion.h2>
|
||||
<div className="relative mt-3 pl-2">
|
||||
<AnimatePresence initial={!isInsideMobileNavigation}>
|
||||
{activeGroup?.title === group.title && (
|
||||
<VisibleSectionHighlight group={group} pathname={pathname} />
|
||||
)}
|
||||
{isActiveGroup && <VisibleSectionHighlight group={group} pathname={pathname} />}
|
||||
</AnimatePresence>
|
||||
<motion.div layout className="absolute inset-y-0 left-2 w-px bg-slate-900/10 dark:bg-white/5" />
|
||||
<AnimatePresence initial={false}>
|
||||
{activeGroup?.title === group.title && (
|
||||
<ActivePageMarker group={group} pathname={pathname || "/docs"} />
|
||||
)}
|
||||
{isActiveGroup && <ActivePageMarker group={group} pathname={pathname || "/docs"} />}
|
||||
</AnimatePresence>
|
||||
<ul
|
||||
role="list"
|
||||
className="border-l border-transparent"
|
||||
onClick={() => {
|
||||
setActiveGroup(group);
|
||||
}}>
|
||||
<ul role="list" className="border-l border-transparent">
|
||||
{group.links.map((link) => (
|
||||
<motion.li key={link.title} layout="position" className="relative">
|
||||
{link.href ? (
|
||||
<NavLink href={link.href} active={pathname.startsWith(link.href)}>
|
||||
<NavLink href={link.href} active={!!pathname?.startsWith(link.href)}>
|
||||
{link.title}
|
||||
</NavLink>
|
||||
) : (
|
||||
<div
|
||||
onClick={() => {
|
||||
setActiveParentTitle(link.title as string);
|
||||
}}>
|
||||
<div onClick={() => toggleParentTitle(link.title)}>
|
||||
<NavLink
|
||||
href={link.children?.[0]?.href || ""}
|
||||
active={
|
||||
(isActiveGroup &&
|
||||
activeGroup?.title === group.title &&
|
||||
!!(
|
||||
isParentOpen(link.title) &&
|
||||
link.children &&
|
||||
link.children.some((child) => pathname.startsWith(child.href))) ||
|
||||
false
|
||||
link.children.some((child) => pathname.startsWith(child.href))
|
||||
)
|
||||
}>
|
||||
<span className="flex w-full justify-between">
|
||||
{link.title}
|
||||
{link.title === activeParentTitle && activeGroup?.title === group.title ? (
|
||||
{isParentOpen(link.title) ? (
|
||||
<ChevronUpIcon className="my-1 h-4" />
|
||||
) : (
|
||||
<ChevronDownIcon className="my-1 h-4" />
|
||||
@@ -222,21 +197,15 @@ function NavigationGroup({
|
||||
</div>
|
||||
)}
|
||||
<AnimatePresence mode="popLayout" initial={false}>
|
||||
{link.children && link.title === activeParentTitle && activeGroup?.title === group.title && (
|
||||
{link.children && isParentOpen(link.title) && (
|
||||
<motion.ul
|
||||
role="list"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
transition: { delay: 0.1 },
|
||||
}}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
transition: { duration: 0.15 },
|
||||
}}>
|
||||
animate={{ opacity: 1, transition: { delay: 0.1 } }}
|
||||
exit={{ opacity: 0, transition: { duration: 0.15 } }}>
|
||||
{link.children.map((child) => (
|
||||
<li key={child.href}>
|
||||
<NavLink href={child.href} isAnchorLink active={pathname.startsWith(child.href)}>
|
||||
<NavLink href={child.href} isAnchorLink active={!!pathname?.startsWith(child.href)}>
|
||||
{child.title}
|
||||
</NavLink>
|
||||
</li>
|
||||
@@ -253,14 +222,12 @@ function NavigationGroup({
|
||||
}
|
||||
|
||||
export function Navigation(props: React.ComponentPropsWithoutRef<"nav">) {
|
||||
const [activeGroup, setActiveGroup] = useState<NavGroup>(navigation[0]);
|
||||
const [activeGroup, setActiveGroup] = useState<NavGroup | null>(navigation[0]);
|
||||
const [openGroups, setOpenGroups] = useState<string[]>([]);
|
||||
|
||||
return (
|
||||
<nav {...props}>
|
||||
<ul role="list">
|
||||
<TopLevelNavItem href="/docs/introduction/what-is-formbricks">Documentation</TopLevelNavItem>
|
||||
<TopLevelNavItem href="https://github.com/formbricks/formbricks">Star us on GitHub</TopLevelNavItem>
|
||||
<TopLevelNavItem href="https://formbricks.com/discord">Join our Discord</TopLevelNavItem>
|
||||
{navigation.map((group, groupIndex) => (
|
||||
<NavigationGroup
|
||||
key={group.title}
|
||||
@@ -268,6 +235,8 @@ export function Navigation(props: React.ComponentPropsWithoutRef<"nav">) {
|
||||
className={groupIndex === 0 ? "md:mt-0" : ""}
|
||||
activeGroup={activeGroup}
|
||||
setActiveGroup={setActiveGroup}
|
||||
openGroups={openGroups}
|
||||
setOpenGroups={setOpenGroups}
|
||||
/>
|
||||
))}
|
||||
<li className="sticky bottom-0 z-10 mt-6 min-[416px]:hidden">
|
||||
|
||||
15
apps/docs/components/ResponsiveVideo.tsx
Normal file
15
apps/docs/components/ResponsiveVideo.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
// ResponsiveVideo.js
|
||||
export function ResponsiveVideo({ src, title }) {
|
||||
return (
|
||||
<div className="relative w-full overflow-hidden pt-[56.25%]">
|
||||
<iframe
|
||||
src={src}
|
||||
title={title}
|
||||
frameBorder="0"
|
||||
className="absolute left-0 top-0 h-full w-full"
|
||||
referrerPolicy="strict-origin-when-cross-origin"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
allowFullScreen></iframe>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -53,13 +53,13 @@ export function Search() {
|
||||
const style = document.createElement("style");
|
||||
style.innerHTML = `
|
||||
:root {
|
||||
--docsearch-primary-color: ${isLightMode ? "#029E94" : "#1F7066"};
|
||||
--docsearch-modal-background: ${isLightMode ? "#FFFFFF" : "#121212"};
|
||||
--docsearch-primary-color: ${isLightMode ? "#00C4B8" : "#00C4B8"};
|
||||
--docsearch-modal-background: ${isLightMode ? "#f8fafc" : "#0f172a"};
|
||||
--docsearch-text-color: ${isLightMode ? "#121212" : "#FFFFFF"};
|
||||
--docsearch-hit-background: ${isLightMode ? "#FFFFFF" : "#111111"};
|
||||
--docsearch-footer-background: ${isLightMode ? "#EEEEEE" : "#121212"};
|
||||
--docsearch-searchbox-focus-background: ${isLightMode ? "#D8F6F4" : "#121212"};
|
||||
--docsearch-modal-shadow: ${isLightMode ? "inset 1px 1px 0 0 hsla(0,0%,100%,0.5), 0 3px 8px 0 #D8F6F4" : "inset 1px 1px 0 0 hsla(0,0%,100%,0.5), 0 3px 8px 0 #808080"};
|
||||
--docsearch-searchbox-focus-background: ${isLightMode ? "#f1f5f9" : "#1e293b"};
|
||||
--docsearch-modal-shadow: "";
|
||||
--DocSearch-Input: ${isLightMode ? "#000000" : "#FFFFFF"};
|
||||
}
|
||||
.DocSearch-Hit-title {
|
||||
@@ -86,6 +86,9 @@ export function Search() {
|
||||
#docsearch-input {
|
||||
background-color: transparent;
|
||||
}
|
||||
.DocSearch-Footer {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
|
||||
15
apps/docs/components/icons/DiscordIcon.tsx
Normal file
15
apps/docs/components/icons/DiscordIcon.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
export const DiscordIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => {
|
||||
return (
|
||||
<svg
|
||||
stroke="currentColor"
|
||||
fill="currentColor"
|
||||
stroke-width="0"
|
||||
viewBox="0 0 640 512"
|
||||
height="1em"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}>
|
||||
<path d="M524.531,69.836a1.5,1.5,0,0,0-.764-.7A485.065,485.065,0,0,0,404.081,32.03a1.816,1.816,0,0,0-1.923.91,337.461,337.461,0,0,0-14.9,30.6,447.848,447.848,0,0,0-134.426,0,309.541,309.541,0,0,0-15.135-30.6,1.89,1.89,0,0,0-1.924-.91A483.689,483.689,0,0,0,116.085,69.137a1.712,1.712,0,0,0-.788.676C39.068,183.651,18.186,294.69,28.43,404.354a2.016,2.016,0,0,0,.765,1.375A487.666,487.666,0,0,0,176.02,479.918a1.9,1.9,0,0,0,2.063-.676A348.2,348.2,0,0,0,208.12,430.4a1.86,1.86,0,0,0-1.019-2.588,321.173,321.173,0,0,1-45.868-21.853,1.885,1.885,0,0,1-.185-3.126c3.082-2.309,6.166-4.711,9.109-7.137a1.819,1.819,0,0,1,1.9-.256c96.229,43.917,200.41,43.917,295.5,0a1.812,1.812,0,0,1,1.924.233c2.944,2.426,6.027,4.851,9.132,7.16a1.884,1.884,0,0,1-.162,3.126,301.407,301.407,0,0,1-45.89,21.83,1.875,1.875,0,0,0-1,2.611,391.055,391.055,0,0,0,30.014,48.815,1.864,1.864,0,0,0,2.063.7A486.048,486.048,0,0,0,610.7,405.729a1.882,1.882,0,0,0,.765-1.352C623.729,277.594,590.933,167.465,524.531,69.836ZM222.491,337.58c-28.972,0-52.844-26.587-52.844-59.239S193.056,219.1,222.491,219.1c29.665,0,53.306,26.82,52.843,59.239C275.334,310.993,251.924,337.58,222.491,337.58Zm195.38,0c-28.971,0-52.843-26.587-52.843-59.239S388.437,219.1,417.871,219.1c29.667,0,53.307,26.82,52.844,59.239C470.715,310.993,447.538,337.58,417.871,337.58Z"></path>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
15
apps/docs/components/icons/GithubIcon.tsx
Normal file
15
apps/docs/components/icons/GithubIcon.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
export const GithubIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => {
|
||||
return (
|
||||
<svg
|
||||
stroke="currentColor"
|
||||
fill="currentColor"
|
||||
stroke-width="0"
|
||||
viewBox="0 0 496 512"
|
||||
height="1em"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}>
|
||||
<path d="M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3.3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5.3-6.2 2.3zm44.2-1.7c-2.9.7-4.9 2.6-4.6 4.9.3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3.7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3.3 2.9 2.3 3.9 1.6 1 3.6.7 4.3-.7.7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3.7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3.7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z"></path>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
15
apps/docs/components/icons/TwitterIcon.tsx
Normal file
15
apps/docs/components/icons/TwitterIcon.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
export const TwitterIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => {
|
||||
return (
|
||||
<svg
|
||||
stroke="currentColor"
|
||||
fill="currentColor"
|
||||
stroke-width="0"
|
||||
viewBox="0 0 512 512"
|
||||
height="1em"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}>
|
||||
<path d="M459.37 151.716c.325 4.548.325 9.097.325 13.645 0 138.72-105.583 298.558-298.558 298.558-59.452 0-114.68-17.219-161.137-47.106 8.447.974 16.568 1.299 25.34 1.299 49.055 0 94.213-16.568 130.274-44.832-46.132-.975-84.792-31.188-98.112-72.772 6.498.974 12.995 1.624 19.818 1.624 9.421 0 18.843-1.3 27.614-3.573-48.081-9.747-84.143-51.98-84.143-102.985v-1.299c13.969 7.797 30.214 12.67 47.431 13.319-28.264-18.843-46.781-51.005-46.781-87.391 0-19.492 5.197-37.36 14.294-52.954 51.655 63.675 129.3 105.258 216.365 109.807-1.624-7.797-2.599-15.918-2.599-24.04 0-57.828 46.782-104.934 104.934-104.934 30.213 0 57.502 12.67 76.67 33.137 23.715-4.548 46.456-13.32 66.599-25.34-7.798 24.366-24.366 44.833-46.132 57.827 21.117-2.273 41.584-8.122 60.426-16.243-14.292 20.791-32.161 39.308-52.628 54.253z"></path>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
@@ -11,7 +11,7 @@ export { CodeGroup, Code as code, Pre as pre } from "@/components/Code";
|
||||
export function wrapper({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<article className="flex h-full flex-col pb-10 pt-16">
|
||||
<Prose className="flex-auto">{children}</Prose>
|
||||
<Prose className="flex-auto font-normal">{children}</Prose>
|
||||
<footer className="mx-auto mt-16 w-full max-w-2xl lg:max-w-5xl">
|
||||
<Feedback />
|
||||
</footer>
|
||||
|
||||
@@ -5,7 +5,7 @@ export const navigation: Array<NavGroup> = [
|
||||
title: "Introduction",
|
||||
links: [
|
||||
{ title: "What is Formbricks?", href: "/introduction/what-is-formbricks" },
|
||||
{ title: "Why is it better?", href: "/introduction/why-is-it-better" },
|
||||
{ title: "Why open source?", href: "/introduction/why-open-source" },
|
||||
{ title: "How does it work?", href: "/introduction/how-it-works" },
|
||||
{
|
||||
title: "Best Practices",
|
||||
|
||||
@@ -20,11 +20,6 @@ const nextConfig = {
|
||||
transpilePackages: ["@formbricks/ui", "@formbricks/lib"],
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "seo-strapi-aws-s3.s3.eu-central-1.amazonaws.com",
|
||||
port: "",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "avatars.githubusercontent.com",
|
||||
@@ -39,6 +34,11 @@ const nextConfig = {
|
||||
destination: "/introduction/what-is-formbricks",
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: "/introduction/why-is-it-better",
|
||||
destination: "/introduction/why-open-source",
|
||||
permanent: true,
|
||||
},
|
||||
// Redirects for Docs 2.0
|
||||
// Self Hosting
|
||||
{
|
||||
@@ -77,6 +77,7 @@ const nextConfig = {
|
||||
destination: "/developer-docs/overview",
|
||||
permanent: true,
|
||||
},
|
||||
|
||||
// Link Survey
|
||||
{
|
||||
source: "/link-surveys/embed-in-email",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "@formbricks/formbricks-com",
|
||||
"name": "@formbricks/docs",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
@@ -19,27 +19,27 @@
|
||||
"@formbricks/lib": "workspace:*",
|
||||
"@formbricks/types": "workspace:*",
|
||||
"@formbricks/ui": "workspace:*",
|
||||
"@headlessui/react": "^1.7.19",
|
||||
"@headlessui/react": "^2.0.3",
|
||||
"@headlessui/tailwindcss": "^0.2.0",
|
||||
"@mapbox/rehype-prism": "^0.9.0",
|
||||
"@mdx-js/loader": "^3.0.1",
|
||||
"@mdx-js/react": "^3.0.1",
|
||||
"@next/mdx": "14.2.1",
|
||||
"@next/mdx": "14.2.3",
|
||||
"@paralleldrive/cuid2": "^2.2.2",
|
||||
"@sindresorhus/slugify": "^2.2.1",
|
||||
"@tailwindcss/typography": "^0.5.12",
|
||||
"@tailwindcss/typography": "^0.5.13",
|
||||
"acorn": "^8.11.3",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"clsx": "^2.1.0",
|
||||
"clsx": "^2.1.1",
|
||||
"fast-glob": "^3.3.2",
|
||||
"flexsearch": "^0.7.43",
|
||||
"framer-motion": "11.1.1",
|
||||
"framer-motion": "11.1.9",
|
||||
"lottie-web": "^5.12.2",
|
||||
"lucide": "^0.368.0",
|
||||
"lucide-react": "^0.368.0",
|
||||
"lucide": "^0.378.0",
|
||||
"lucide-react": "^0.378.0",
|
||||
"mdast-util-to-string": "^4.0.0",
|
||||
"mdx-annotations": "^0.1.4",
|
||||
"next": "14.1.3",
|
||||
"next": "14.2.3",
|
||||
"next-plausible": "^3.12.0",
|
||||
"next-seo": "^6.5.0",
|
||||
"next-sitemap": "^4.2.3",
|
||||
@@ -47,10 +47,9 @@
|
||||
"node-fetch": "^3.3.2",
|
||||
"prism-react-renderer": "^2.3.1",
|
||||
"prismjs": "^1.29.0",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-highlight-words": "^0.20.0",
|
||||
"react-icons": "^5.1.0",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-responsive-embed": "^2.1.0",
|
||||
"remark": "^15.0.1",
|
||||
|
||||
9
apps/docs/public/robots.txt
Normal file
9
apps/docs/public/robots.txt
Normal file
@@ -0,0 +1,9 @@
|
||||
# *
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
# Host
|
||||
Host: https://formbricks.com
|
||||
|
||||
# Sitemaps
|
||||
Sitemap: https://formbricks.com/sitemap.xml
|
||||
@@ -68,7 +68,7 @@ export default {
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ["Poppins", ...defaultTheme.fontFamily.sans],
|
||||
sans: ["Jost", ...defaultTheme.fontFamily.sans],
|
||||
display: ["Lexend", ...defaultTheme.fontFamily.sans],
|
||||
kablammo: ["Kablammo", "sans"],
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"extends": "@formbricks/tsconfig/nextjs.json",
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "../../packages/types/*.d.ts"],
|
||||
"exclude": ["../../.env"],
|
||||
"exclude": ["../../.env", "node_modules"],
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
|
||||
@@ -42,7 +42,7 @@ export default function typographyStyles({ theme }: PluginUtils) {
|
||||
|
||||
// Base
|
||||
color: "var(--tw-prose-body)",
|
||||
fontSize: theme("fontSize.sm")[0],
|
||||
fontSize: theme("fontSize.base")[0],
|
||||
lineHeight: theme("lineHeight.7"),
|
||||
|
||||
// Layout
|
||||
@@ -192,14 +192,14 @@ export default function typographyStyles({ theme }: PluginUtils) {
|
||||
color: "var(--tw-prose-headings)",
|
||||
fontWeight: "600",
|
||||
fontSize: theme("fontSize.lg")[0],
|
||||
...theme("fontSize.lg")[1],
|
||||
...theme("fontSize.xl")[1],
|
||||
marginTop: theme("spacing.16"),
|
||||
marginBottom: theme("spacing.2"),
|
||||
},
|
||||
h3: {
|
||||
color: "var(--tw-prose-headings)",
|
||||
fontSize: theme("fontSize.base")[0],
|
||||
...theme("fontSize.base")[1],
|
||||
...theme("fontSize.lg")[1],
|
||||
fontWeight: "600",
|
||||
marginTop: theme("spacing.10"),
|
||||
marginBottom: theme("spacing.2"),
|
||||
|
||||
@@ -12,24 +12,24 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@formbricks/ui": "workspace:*",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@formbricks/tsconfig": "workspace:*",
|
||||
"@storybook/addon-essentials": "^8.0.9",
|
||||
"@storybook/addon-interactions": "^8.0.9",
|
||||
"@storybook/addon-links": "^8.0.9",
|
||||
"@storybook/addon-onboarding": "^8.0.9",
|
||||
"@storybook/blocks": "^8.0.9",
|
||||
"@storybook/react": "^8.0.9",
|
||||
"@storybook/react-vite": "^8.0.9",
|
||||
"@storybook/addon-essentials": "^8.0.10",
|
||||
"@storybook/addon-interactions": "^8.0.10",
|
||||
"@storybook/addon-links": "^8.0.10",
|
||||
"@storybook/addon-onboarding": "^8.0.10",
|
||||
"@storybook/blocks": "^8.0.10",
|
||||
"@storybook/react": "^8.0.10",
|
||||
"@storybook/react-vite": "^8.0.10",
|
||||
"@storybook/testing-library": "^0.2.2",
|
||||
"@typescript-eslint/eslint-plugin": "^7.7.1",
|
||||
"@typescript-eslint/parser": "^7.7.1",
|
||||
"@typescript-eslint/eslint-plugin": "^7.8.0",
|
||||
"@typescript-eslint/parser": "^7.8.0",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"esbuild": "^0.20.2",
|
||||
"esbuild": "^0.21.1",
|
||||
"tsup": "^8.0.2",
|
||||
"vite": "^5.2.10"
|
||||
"vite": "^5.2.11"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import FormbricksClient from "@/app/(app)/components/FormbricksClient";
|
||||
import PosthogIdentify from "@/app/(app)/environments/[environmentId]/components/PosthogIdentify";
|
||||
import { ResponseFilterProvider } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
|
||||
import { getEnvironment } from "@formbricks/lib/environment/service";
|
||||
import { getTeamByEnvironmentId } from "@formbricks/lib/team/service";
|
||||
import { AuthorizationError } from "@formbricks/types/errors";
|
||||
import { DevEnvironmentBanner } from "@formbricks/ui/DevEnvironmentBanner";
|
||||
import ToasterClient from "@formbricks/ui/ToasterClient";
|
||||
|
||||
export default async function EnvLayout({ children, params }) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session || !session.user) {
|
||||
return redirect(`/auth/login`);
|
||||
}
|
||||
const hasAccess = await hasUserEnvironmentAccess(session.user.id, params.environmentId);
|
||||
if (!hasAccess) {
|
||||
throw new AuthorizationError("Not authorized");
|
||||
}
|
||||
|
||||
const team = await getTeamByEnvironmentId(params.environmentId);
|
||||
if (!team) {
|
||||
throw new Error("Team not found");
|
||||
}
|
||||
|
||||
const environment = await getEnvironment(params.environmentId);
|
||||
|
||||
if (!environment) {
|
||||
throw new Error("Environment not found");
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ResponseFilterProvider>
|
||||
<PosthogIdentify
|
||||
session={session}
|
||||
environmentId={params.environmentId}
|
||||
teamId={team.id}
|
||||
teamName={team.name}
|
||||
inAppSurveyBillingStatus={team.billing.features.inAppSurvey.status}
|
||||
linkSurveyBillingStatus={team.billing.features.linkSurvey.status}
|
||||
userTargetingBillingStatus={team.billing.features.userTargeting.status}
|
||||
/>
|
||||
<FormbricksClient session={session} />
|
||||
<ToasterClient />
|
||||
<div className="flex h-screen flex-col">
|
||||
<DevEnvironmentBanner environment={environment} />
|
||||
<div className="h-full overflow-y-auto bg-slate-50">{children}</div>
|
||||
</div>
|
||||
</ResponseFilterProvider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { getServerSession } from "next-auth";
|
||||
|
||||
import { createActionClass } from "@formbricks/lib/actionClass/service";
|
||||
import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import { UNSPLASH_ACCESS_KEY } from "@formbricks/lib/constants";
|
||||
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
loadNewSegmentInSurvey,
|
||||
updateSurvey,
|
||||
} from "@formbricks/lib/survey/service";
|
||||
import { TActionClassInput } from "@formbricks/types/actionClasses";
|
||||
import { AuthorizationError } from "@formbricks/types/errors";
|
||||
import { TProduct } from "@formbricks/types/product";
|
||||
import { TBaseFilters, TSegmentUpdateInput, ZSegmentFilters } from "@formbricks/types/segment";
|
||||
@@ -59,7 +61,7 @@ export const deleteSurveyAction = async (surveyId: string) => {
|
||||
await deleteSurvey(surveyId);
|
||||
};
|
||||
|
||||
export const refetchProduct = async (productId: string): Promise<TProduct | null> => {
|
||||
export const refetchProductAction = async (productId: string): Promise<TProduct | null> => {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new AuthorizationError("Not authorized");
|
||||
|
||||
@@ -248,3 +250,13 @@ export async function triggerDownloadUnsplashImageAction(downloadUrl: string) {
|
||||
throw new Error("Error downloading image from Unsplash");
|
||||
}
|
||||
}
|
||||
|
||||
export async function createActionClassAction(action: TActionClassInput) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new AuthorizationError("Not authorized");
|
||||
|
||||
const isAuthorized = await hasUserEnvironmentAccess(session.user.id, action.environmentId);
|
||||
if (!isAuthorized) throw new AuthorizationError("Not authorized");
|
||||
|
||||
return await createActionClass(action.environmentId, action);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import { TActionClass } from "@formbricks/types/actionClasses";
|
||||
import { TSurvey } from "@formbricks/types/surveys";
|
||||
import { ModalWithTabs } from "@formbricks/ui/ModalWithTabs";
|
||||
|
||||
import { CreateNewActionTab } from "./CreateNewActionTab";
|
||||
import { SavedActionsTab } from "./SavedActionsTab";
|
||||
|
||||
interface AddActionModalProps {
|
||||
open: boolean;
|
||||
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
environmentId: string;
|
||||
actionClasses: TActionClass[];
|
||||
setActionClasses: React.Dispatch<React.SetStateAction<TActionClass[]>>;
|
||||
isViewer: boolean;
|
||||
localSurvey: TSurvey;
|
||||
setLocalSurvey: React.Dispatch<React.SetStateAction<TSurvey>>;
|
||||
}
|
||||
|
||||
export const AddActionModal = ({
|
||||
open,
|
||||
setOpen,
|
||||
actionClasses,
|
||||
setActionClasses,
|
||||
localSurvey,
|
||||
setLocalSurvey,
|
||||
isViewer,
|
||||
environmentId,
|
||||
}: AddActionModalProps) => {
|
||||
const tabs = [
|
||||
{
|
||||
title: "Select saved action",
|
||||
children: (
|
||||
<SavedActionsTab
|
||||
actionClasses={actionClasses}
|
||||
localSurvey={localSurvey}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
setOpen={setOpen}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Capture new action",
|
||||
children: (
|
||||
<CreateNewActionTab
|
||||
actionClasses={actionClasses}
|
||||
setActionClasses={setActionClasses}
|
||||
setOpen={setOpen}
|
||||
isViewer={isViewer}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
environmentId={environmentId}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
return (
|
||||
<ModalWithTabs
|
||||
label="Add action"
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
tabs={tabs}
|
||||
size="md"
|
||||
closeOnOutsideClick={false}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,6 @@
|
||||
import LogicEditor from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/LogicEditor";
|
||||
|
||||
import { TSurvey, TSurveyQuestion } from "@formbricks/types/surveys";
|
||||
|
||||
import LogicEditor from "./LogicEditor";
|
||||
import UpdateQuestionId from "./UpdateQuestionId";
|
||||
|
||||
interface AdvancedSettingsProps {
|
||||
@@ -17,23 +17,23 @@ interface BackgroundStylingCardProps {
|
||||
styling: TSurveyStyling | TProductStyling | null;
|
||||
setStyling: React.Dispatch<React.SetStateAction<TSurveyStyling | TProductStyling>>;
|
||||
colors: string[];
|
||||
hideCheckmark?: boolean;
|
||||
isSettingsPage?: boolean;
|
||||
disabled?: boolean;
|
||||
environmentId: string;
|
||||
isUnsplashConfigured: boolean;
|
||||
}
|
||||
|
||||
export default function BackgroundStylingCard({
|
||||
export const BackgroundStylingCard = ({
|
||||
open,
|
||||
setOpen,
|
||||
styling,
|
||||
setStyling,
|
||||
colors,
|
||||
hideCheckmark,
|
||||
isSettingsPage = false,
|
||||
disabled,
|
||||
environmentId,
|
||||
isUnsplashConfigured,
|
||||
}: BackgroundStylingCardProps) {
|
||||
}: BackgroundStylingCardProps) => {
|
||||
const { bgType, brightness } = styling?.background ?? {};
|
||||
|
||||
const handleBgChange = (color: string, type: TSurveyBackgroundBgType) => {
|
||||
@@ -79,7 +79,7 @@ export default function BackgroundStylingCard({
|
||||
disabled && "cursor-not-allowed opacity-60 hover:bg-white"
|
||||
)}>
|
||||
<div className="inline-flex px-4 py-4">
|
||||
{!hideCheckmark && (
|
||||
{!isSettingsPage && (
|
||||
<div className="flex items-center pl-2 pr-5">
|
||||
<CheckIcon
|
||||
strokeWidth={3}
|
||||
@@ -89,10 +89,12 @@ export default function BackgroundStylingCard({
|
||||
)}
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-semibold text-slate-800">Background Styling</p>
|
||||
{hideCheckmark && <Badge text="Link Surveys" type="gray" size="normal" />}
|
||||
<p className={cn("font-semibold text-slate-800", isSettingsPage ? "text-sm" : "text-base")}>
|
||||
Background Styling
|
||||
</p>
|
||||
{isSettingsPage && <Badge text="Link Surveys" type="gray" size="normal" />}
|
||||
</div>
|
||||
<p className="mt-1 truncate text-sm text-slate-500">
|
||||
<p className={cn("mt-1 text-slate-500", isSettingsPage ? "text-xs" : "text-sm")}>
|
||||
Change the background to a color, image or animation.
|
||||
</p>
|
||||
</div>
|
||||
@@ -148,4 +150,4 @@ export default function BackgroundStylingCard({
|
||||
</Collapsible.CollapsibleContent>
|
||||
</Collapsible.Root>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -7,12 +7,13 @@ import React, { useMemo } from "react";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { COLOR_DEFAULTS } from "@formbricks/lib/styling/constants";
|
||||
import { TProduct, TProductStyling } from "@formbricks/types/product";
|
||||
import { TCardArrangementOptions } from "@formbricks/types/styling";
|
||||
import { TSurveyStyling, TSurveyType } from "@formbricks/types/surveys";
|
||||
import { Badge } from "@formbricks/ui/Badge";
|
||||
import { ColorPicker } from "@formbricks/ui/ColorPicker";
|
||||
import { Label } from "@formbricks/ui/Label";
|
||||
import { Slider } from "@formbricks/ui/Slider";
|
||||
import { ColorSelectorWithLabel } from "@formbricks/ui/Styling";
|
||||
import { CardArrangement, ColorSelectorWithLabel } from "@formbricks/ui/Styling";
|
||||
import { Switch } from "@formbricks/ui/Switch";
|
||||
|
||||
type CardStylingSettingsProps = {
|
||||
@@ -20,16 +21,16 @@ type CardStylingSettingsProps = {
|
||||
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
styling: TSurveyStyling | TProductStyling | null;
|
||||
setStyling: React.Dispatch<React.SetStateAction<TSurveyStyling | TProductStyling>>;
|
||||
hideCheckmark?: boolean;
|
||||
isSettingsPage?: boolean;
|
||||
surveyType?: TSurveyType;
|
||||
disabled?: boolean;
|
||||
localProduct: TProduct;
|
||||
};
|
||||
|
||||
const CardStylingSettings = ({
|
||||
export const CardStylingSettings = ({
|
||||
setStyling,
|
||||
styling,
|
||||
hideCheckmark,
|
||||
isSettingsPage = false,
|
||||
surveyType,
|
||||
disabled,
|
||||
open,
|
||||
@@ -43,6 +44,10 @@ const CardStylingSettings = ({
|
||||
|
||||
const isLogoVisible = !!localProduct.logo?.url;
|
||||
|
||||
const linkSurveyCardArrangement = styling?.cardArrangement?.linkSurveys ?? "straight";
|
||||
|
||||
const inAppSurveyCardArrangement = styling?.cardArrangement?.appSurveys ?? "straight";
|
||||
|
||||
const setCardBgColor = (color: string) => {
|
||||
setStyling((prev) => ({
|
||||
...prev,
|
||||
@@ -113,6 +118,24 @@ const CardStylingSettings = ({
|
||||
}));
|
||||
};
|
||||
|
||||
const setCardArrangement = (arrangement: TCardArrangementOptions, surveyType: TSurveyType) => {
|
||||
const newCardArrangement = {
|
||||
linkSurveys: linkSurveyCardArrangement,
|
||||
appSurveys: inAppSurveyCardArrangement,
|
||||
};
|
||||
|
||||
if (surveyType === "link") {
|
||||
newCardArrangement.linkSurveys = arrangement;
|
||||
} else if (surveyType === "app" || surveyType === "website") {
|
||||
newCardArrangement.appSurveys = arrangement;
|
||||
}
|
||||
|
||||
setStyling((prev) => ({
|
||||
...prev,
|
||||
cardArrangement: newCardArrangement,
|
||||
}));
|
||||
};
|
||||
|
||||
const toggleProgressBarVisibility = (hideProgressBar: boolean) => {
|
||||
setStyling({
|
||||
...styling,
|
||||
@@ -147,7 +170,7 @@ const CardStylingSettings = ({
|
||||
disabled && "cursor-not-allowed opacity-60 hover:bg-white"
|
||||
)}>
|
||||
<div className="inline-flex px-4 py-4">
|
||||
{!hideCheckmark && (
|
||||
{!isSettingsPage && (
|
||||
<div className="flex items-center pl-2 pr-5">
|
||||
<CheckIcon
|
||||
strokeWidth={3}
|
||||
@@ -157,8 +180,12 @@ const CardStylingSettings = ({
|
||||
)}
|
||||
|
||||
<div>
|
||||
<p className="font-semibold text-slate-800">Card Styling</p>
|
||||
<p className="mt-1 text-sm text-slate-500">Style the survey card.</p>
|
||||
<p className={cn("font-semibold text-slate-800", isSettingsPage ? "text-sm" : "text-base")}>
|
||||
Card Styling
|
||||
</p>
|
||||
<p className={cn("mt-1 text-slate-500", isSettingsPage ? "text-xs" : "text-sm")}>
|
||||
Style the survey card.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Collapsible.CollapsibleTrigger>
|
||||
@@ -198,6 +225,12 @@ const CardStylingSettings = ({
|
||||
description="Change the shadow color of the card."
|
||||
/>
|
||||
|
||||
<CardArrangement
|
||||
surveyType={isAppSurvey ? "app" : "link"}
|
||||
activeCardArrangement={isAppSurvey ? inAppSurveyCardArrangement : linkSurveyCardArrangement}
|
||||
setActiveCardArrangement={setCardArrangement}
|
||||
/>
|
||||
|
||||
<>
|
||||
<div className="flex items-center space-x-1">
|
||||
<Switch
|
||||
@@ -215,14 +248,14 @@ const CardStylingSettings = ({
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{isLogoVisible && (!surveyType || surveyType === "link") && (
|
||||
{isLogoVisible && (!surveyType || surveyType === "link") && !isSettingsPage && (
|
||||
<div className="flex items-center space-x-1">
|
||||
<Switch id="isLogoHidden" checked={isLogoHidden} onCheckedChange={toggleLogoVisibility} />
|
||||
<Label htmlFor="isLogoHidden" className="cursor-pointer">
|
||||
<div className="ml-2 flex flex-col">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-semibold text-slate-700">Hide logo</h3>
|
||||
{hideCheckmark && <Badge text="Link Surveys" type="gray" size="normal" />}
|
||||
<Badge text="Link Surveys" type="gray" size="normal" />
|
||||
</div>
|
||||
<p className="text-xs font-normal text-slate-500">
|
||||
Hides the logo in this specific survey
|
||||
@@ -238,8 +271,15 @@ const CardStylingSettings = ({
|
||||
<Switch checked={isHighlightBorderAllowed} onCheckedChange={setIsHighlightBorderAllowed} />
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-semibold text-slate-700">Add highlight border</h3>
|
||||
<Badge text="In-App and Website Surveys" type="gray" size="normal" />
|
||||
<h3 className="whitespace-nowrap text-sm font-semibold text-slate-700">
|
||||
Add highlight border
|
||||
</h3>
|
||||
<Badge
|
||||
text="App & Website Surveys"
|
||||
type="gray"
|
||||
size="normal"
|
||||
className="whitespace-nowrap"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500">Add an outer border to your survey card.</p>
|
||||
</div>
|
||||
@@ -260,5 +300,3 @@ const CardStylingSettings = ({
|
||||
</Collapsible.Root>
|
||||
);
|
||||
};
|
||||
|
||||
export default CardStylingSettings;
|
||||
@@ -0,0 +1,297 @@
|
||||
import { isValidCssSelector } from "@/app/lib/actionClass/actionClass";
|
||||
import { Terminal } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { MatchType, testURLmatch } from "@formbricks/lib/utils/testUrlMatch";
|
||||
import { TActionClass, TActionClassInput, TActionClassNoCodeConfig } from "@formbricks/types/actionClasses";
|
||||
import { TSurvey } from "@formbricks/types/surveys";
|
||||
import { CssSelector, InnerHtmlSelector, PageUrlSelector } from "@formbricks/ui/Actions";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@formbricks/ui/Alert";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { Input } from "@formbricks/ui/Input";
|
||||
import { Label } from "@formbricks/ui/Label";
|
||||
import { TabBar } from "@formbricks/ui/TabBar";
|
||||
|
||||
import { createActionClassAction } from "../actions";
|
||||
|
||||
interface CreateNewActionTabProps {
|
||||
actionClasses: TActionClass[];
|
||||
setActionClasses: React.Dispatch<React.SetStateAction<TActionClass[]>>;
|
||||
isViewer: boolean;
|
||||
setLocalSurvey?: React.Dispatch<React.SetStateAction<TSurvey>>;
|
||||
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
environmentId: string;
|
||||
}
|
||||
|
||||
export const CreateNewActionTab = ({
|
||||
actionClasses,
|
||||
setActionClasses,
|
||||
setOpen,
|
||||
isViewer,
|
||||
setLocalSurvey,
|
||||
environmentId,
|
||||
}: CreateNewActionTabProps) => {
|
||||
const { register, control, handleSubmit, watch, reset } = useForm<TActionClass>({
|
||||
defaultValues: {
|
||||
name: "",
|
||||
description: "",
|
||||
type: "noCode",
|
||||
key: "",
|
||||
noCodeConfig: {
|
||||
pageUrl: {
|
||||
rule: "contains",
|
||||
value: "",
|
||||
},
|
||||
cssSelector: {
|
||||
value: "",
|
||||
},
|
||||
innerHtml: {
|
||||
value: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const [type, setType] = useState("noCode");
|
||||
|
||||
const [isPageUrl, setIsPageUrl] = useState(false);
|
||||
const [isCssSelector, setIsCssSelector] = useState(false);
|
||||
const [isInnerHtml, setIsInnerText] = useState(false);
|
||||
const [isCreatingAction, setIsCreatingAction] = useState(false);
|
||||
const [testUrl, setTestUrl] = useState("");
|
||||
const [isMatch, setIsMatch] = useState("");
|
||||
const actionClassNames = useMemo(
|
||||
() => actionClasses.map((actionClass) => actionClass.name),
|
||||
[actionClasses]
|
||||
);
|
||||
|
||||
const actionClassKeys = useMemo(
|
||||
() =>
|
||||
actionClasses
|
||||
.filter((actionClass) => actionClass.type === "code")
|
||||
.map((actionClass) => actionClass.key),
|
||||
[actionClasses]
|
||||
);
|
||||
|
||||
const filterNoCodeConfig = (noCodeConfig: TActionClassNoCodeConfig): TActionClassNoCodeConfig => {
|
||||
const { pageUrl, innerHtml, cssSelector } = noCodeConfig;
|
||||
const filteredNoCodeConfig: TActionClassNoCodeConfig = {};
|
||||
|
||||
if (isPageUrl && pageUrl?.rule && pageUrl?.value) {
|
||||
filteredNoCodeConfig.pageUrl = { rule: pageUrl.rule, value: pageUrl.value };
|
||||
}
|
||||
if (isInnerHtml && innerHtml?.value) {
|
||||
filteredNoCodeConfig.innerHtml = { value: innerHtml.value };
|
||||
}
|
||||
if (isCssSelector && cssSelector?.value) {
|
||||
filteredNoCodeConfig.cssSelector = { value: cssSelector.value };
|
||||
}
|
||||
|
||||
return filteredNoCodeConfig;
|
||||
};
|
||||
|
||||
const handleMatchClick = () => {
|
||||
const match = testURLmatch(
|
||||
testUrl,
|
||||
watch("noCodeConfig.pageUrl.value"),
|
||||
watch("noCodeConfig.pageUrl.rule") as MatchType
|
||||
);
|
||||
|
||||
setIsMatch(match);
|
||||
if (match === "yes") toast.success("Your survey would be shown on this URL.");
|
||||
if (match === "no") toast.error("Your survey would not be shown.");
|
||||
};
|
||||
|
||||
const submitHandler = async (data: Partial<TActionClass>) => {
|
||||
const { noCodeConfig } = data;
|
||||
try {
|
||||
if (isViewer) {
|
||||
throw new Error("You are not authorised to perform this action.");
|
||||
}
|
||||
setIsCreatingAction(true);
|
||||
|
||||
if (!data.name || data.name?.trim() === "") {
|
||||
throw new Error("Please give your action a name");
|
||||
}
|
||||
if (data.name && actionClassNames.includes(data.name)) {
|
||||
throw new Error(`Action with name ${data.name} already exist`);
|
||||
}
|
||||
if (type === "noCode") {
|
||||
if (!isPageUrl && !isCssSelector && !isInnerHtml)
|
||||
throw new Error("Please select at least one selector");
|
||||
|
||||
if (isCssSelector && !isValidCssSelector(noCodeConfig?.cssSelector?.value))
|
||||
throw new Error("Please enter a valid CSS Selector");
|
||||
|
||||
if (isPageUrl && noCodeConfig?.pageUrl?.rule === undefined)
|
||||
throw new Error("Please select a rule for page URL");
|
||||
}
|
||||
if (type === "code" && !data.key) {
|
||||
throw new Error("Please enter a code key");
|
||||
}
|
||||
if (data.key && actionClassKeys.includes(data.key)) {
|
||||
throw new Error(`Action with key ${data.key} already exist`);
|
||||
}
|
||||
|
||||
const updatedAction: TActionClassInput = {
|
||||
name: data.name.trim(),
|
||||
description: data.description,
|
||||
environmentId,
|
||||
type: type as TActionClass["type"],
|
||||
};
|
||||
|
||||
if (type === "noCode") {
|
||||
const filteredNoCodeConfig = filterNoCodeConfig(noCodeConfig as TActionClassNoCodeConfig);
|
||||
updatedAction.noCodeConfig = filteredNoCodeConfig;
|
||||
} else {
|
||||
updatedAction.key = data.key;
|
||||
}
|
||||
|
||||
const newActionClass: TActionClass = await createActionClassAction(updatedAction);
|
||||
if (setActionClasses) {
|
||||
setActionClasses((prevActionClasses: TActionClass[]) => [...prevActionClasses, newActionClass]);
|
||||
}
|
||||
|
||||
if (setLocalSurvey) {
|
||||
setLocalSurvey((prev) => ({
|
||||
...prev,
|
||||
triggers: prev.triggers.concat({ actionClass: newActionClass }),
|
||||
}));
|
||||
}
|
||||
|
||||
reset();
|
||||
resetAllStates();
|
||||
} catch (e: any) {
|
||||
toast.error(e.message);
|
||||
} finally {
|
||||
setIsCreatingAction(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resetAllStates = () => {
|
||||
setType("noCode");
|
||||
setIsCssSelector(false);
|
||||
setIsPageUrl(false);
|
||||
setIsInnerText(false);
|
||||
setTestUrl("");
|
||||
setIsMatch("");
|
||||
reset();
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<form onSubmit={handleSubmit(submitHandler)}>
|
||||
<div className="w-full space-y-4">
|
||||
<div className="grid w-full grid-cols-2 gap-x-4">
|
||||
<div className="col-span-1">
|
||||
<Label htmlFor="actionNameInput">What did your user do?</Label>
|
||||
<Input id="actionNameInput" placeholder="E.g. Clicked Download" {...register("name")} />
|
||||
</div>
|
||||
<div className="col-span-1">
|
||||
<Label htmlFor="actionDescriptionInput">Description</Label>
|
||||
<Input
|
||||
id="actionDescriptionInput"
|
||||
placeholder="User clicked Download Button "
|
||||
{...register("description")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<Label>Type</Label>
|
||||
<div className="w-3/5">
|
||||
<TabBar
|
||||
tabs={[
|
||||
{
|
||||
id: "noCode",
|
||||
label: "No code",
|
||||
},
|
||||
{
|
||||
id: "code",
|
||||
label: "Code",
|
||||
},
|
||||
]}
|
||||
activeId={type}
|
||||
setActiveId={setType}
|
||||
tabStyle="button"
|
||||
className="rounded-md bg-white"
|
||||
activeTabClassName="bg-slate-100"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-h-60 overflow-y-auto">
|
||||
{type === "code" ? (
|
||||
<>
|
||||
<div className="col-span-1">
|
||||
<Label htmlFor="codeActionKeyInput">Key</Label>
|
||||
<Input
|
||||
id="codeActionKeyInput"
|
||||
placeholder="e.g. download_cta_click_on_home"
|
||||
{...register("key")}
|
||||
className="mb-2 w-1/2"
|
||||
/>
|
||||
</div>
|
||||
<Alert className="bg-slate-100">
|
||||
<Terminal className="h-4 w-4" />
|
||||
<AlertTitle>How do Code Actions work?</AlertTitle>
|
||||
<AlertDescription>
|
||||
You can track code action anywhere in your app using{" "}
|
||||
<span className="rounded bg-white px-2 py-1 text-xs">
|
||||
formbricks.track("{watch("key")}")
|
||||
</span>{" "}
|
||||
in your code. Read more in our{" "}
|
||||
<a href="https://formbricks.com/docs/actions/code" target="_blank" className="underline">
|
||||
docs
|
||||
</a>
|
||||
.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<Label>Select By</Label>
|
||||
</div>
|
||||
<CssSelector
|
||||
isCssSelector={isCssSelector}
|
||||
setIsCssSelector={setIsCssSelector}
|
||||
register={register}
|
||||
/>
|
||||
<PageUrlSelector
|
||||
isPageUrl={isPageUrl}
|
||||
setIsPageUrl={setIsPageUrl}
|
||||
register={register}
|
||||
control={control}
|
||||
testUrl={testUrl}
|
||||
setTestUrl={setTestUrl}
|
||||
isMatch={isMatch}
|
||||
setIsMatch={setIsMatch}
|
||||
handleMatchClick={handleMatchClick}
|
||||
/>
|
||||
<InnerHtmlSelector
|
||||
isInnerHtml={isInnerHtml}
|
||||
setIsInnerHtml={setIsInnerText}
|
||||
register={register}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end pt-6">
|
||||
<div className="flex space-x-2">
|
||||
<Button type="button" variant="minimal" onClick={resetAllStates}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="darkCTA" type="submit" loading={isCreatingAction}>
|
||||
Create action
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -17,15 +17,15 @@ type FormStylingSettingsProps = {
|
||||
setStyling: React.Dispatch<React.SetStateAction<TSurveyStyling | TProductStyling>>;
|
||||
open: boolean;
|
||||
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
hideCheckmark?: boolean;
|
||||
isSettingsPage?: boolean;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
const FormStylingSettings = ({
|
||||
export const FormStylingSettings = ({
|
||||
styling,
|
||||
setStyling,
|
||||
open,
|
||||
hideCheckmark = false,
|
||||
isSettingsPage = false,
|
||||
disabled = false,
|
||||
setOpen,
|
||||
}: FormStylingSettingsProps) => {
|
||||
@@ -134,7 +134,7 @@ const FormStylingSettings = ({
|
||||
disabled && "cursor-not-allowed opacity-60 hover:bg-white"
|
||||
)}>
|
||||
<div className="inline-flex px-4 py-4">
|
||||
{!hideCheckmark && (
|
||||
{!isSettingsPage && (
|
||||
<div className="flex items-center pl-2 pr-5">
|
||||
<CheckIcon
|
||||
strokeWidth={3}
|
||||
@@ -144,8 +144,10 @@ const FormStylingSettings = ({
|
||||
)}
|
||||
|
||||
<div>
|
||||
<p className="font-semibold text-slate-800">Form Styling</p>
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
<p className={cn("font-semibold text-slate-800", isSettingsPage ? "text-sm" : "text-base")}>
|
||||
Form Styling
|
||||
</p>
|
||||
<p className={cn("mt-1 text-slate-500", isSettingsPage ? "text-xs" : "text-sm")}>
|
||||
Style the question texts, descriptions and input fields.
|
||||
</p>
|
||||
</div>
|
||||
@@ -199,5 +201,3 @@ const FormStylingSettings = ({
|
||||
</Collapsible.Root>
|
||||
);
|
||||
};
|
||||
|
||||
export default FormStylingSettings;
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { validateId } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/lib/validation";
|
||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
import { FC, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
@@ -13,6 +12,8 @@ import { Label } from "@formbricks/ui/Label";
|
||||
import { Switch } from "@formbricks/ui/Switch";
|
||||
import { Tag } from "@formbricks/ui/Tag";
|
||||
|
||||
import { validateId } from "../lib/validation";
|
||||
|
||||
interface HiddenFieldsCardProps {
|
||||
localSurvey: TSurvey;
|
||||
setLocalSurvey: (survey: TSurvey) => void;
|
||||
@@ -182,7 +182,7 @@ export default function HowToSendCard({ localSurvey, setLocalSurvey, environment
|
||||
</p>
|
||||
<p className="text-xs font-normal">
|
||||
<Link
|
||||
href={`/environments/${environment.id}/settings/setup`}
|
||||
href={`/environments/${environment.id}/product/setup`}
|
||||
className="underline hover:text-amber-900"
|
||||
target="_blank">
|
||||
Connect Formbricks
|
||||
@@ -1,8 +1,8 @@
|
||||
import { HelpCircle, TrashIcon } from "lucide-react";
|
||||
import { ChevronDown, SplitIcon } from "lucide-react";
|
||||
import { CornerDownRightIcon, MoveDownIcon } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { BsArrowDown, BsArrowReturnRight } from "react-icons/bs";
|
||||
|
||||
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
|
||||
import { structuredClone } from "@formbricks/lib/pollyfills/structuredClone";
|
||||
@@ -269,7 +269,7 @@ export default function LogicEditor({
|
||||
<div className="mt-2 space-y-3">
|
||||
{question?.logic?.map((logic, logicIdx) => (
|
||||
<div key={logicIdx} className="flex items-center space-x-2 space-y-1 text-xs xl:text-sm">
|
||||
<BsArrowReturnRight className="h-4 w-4" />
|
||||
<CornerDownRightIcon className="h-4 w-4" />
|
||||
<p className="text-slate-800">If this answer</p>
|
||||
|
||||
<Select value={logic.condition} onValueChange={(e) => updateLogic(logicIdx, { condition: e })}>
|
||||
@@ -385,7 +385,7 @@ export default function LogicEditor({
|
||||
</div>
|
||||
))}
|
||||
<div className="flex flex-wrap items-center space-x-2 py-1 text-sm">
|
||||
<BsArrowDown className="h-4 w-4" />
|
||||
<MoveDownIcon className="h-4 w-4" />
|
||||
<p className="text-slate-700">All other answers will continue to the next question</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { isLabelValidForAllLanguages } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/lib/validation";
|
||||
import { PlusIcon, TrashIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
@@ -11,6 +10,8 @@ import { Button } from "@formbricks/ui/Button";
|
||||
import { Label } from "@formbricks/ui/Label";
|
||||
import { QuestionFormInput } from "@formbricks/ui/QuestionFormInput";
|
||||
|
||||
import { isLabelValidForAllLanguages } from "../lib/validation";
|
||||
|
||||
interface MatrixQuestionFormProps {
|
||||
localSurvey: TSurvey;
|
||||
question: TSurveyMatrixQuestion;
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { isLabelValidForAllLanguages } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/lib/validation";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { PlusIcon, TrashIcon } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
@@ -20,6 +19,8 @@ import { Label } from "@formbricks/ui/Label";
|
||||
import { QuestionFormInput } from "@formbricks/ui/QuestionFormInput";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@formbricks/ui/Select";
|
||||
|
||||
import { isLabelValidForAllLanguages } from "../lib/validation";
|
||||
|
||||
interface OpenQuestionFormProps {
|
||||
localSurvey: TSurvey;
|
||||
question: TSurveyMultipleChoiceSingleQuestion;
|
||||
@@ -1,10 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { getPlacementStyle } from "@/app/lib/preview";
|
||||
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { TPlacement } from "@formbricks/types/common";
|
||||
import { Label } from "@formbricks/ui/Label";
|
||||
import { getPlacementStyle } from "@formbricks/ui/PreviewSurvey/lib/utils";
|
||||
import { RadioGroup, RadioGroupItem } from "@formbricks/ui/RadioGroup";
|
||||
|
||||
const placements = [
|
||||
@@ -1,19 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { AddressQuestionForm } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/AddressQuestionForm";
|
||||
import { AdvancedSettings } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/AdvancedSettings";
|
||||
import { CTAQuestionForm } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/CTAQuestionForm";
|
||||
import { CalQuestionForm } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/CalQuestionForm";
|
||||
import { ConsentQuestionForm } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/ConsentQuestionForm";
|
||||
import { DateQuestionForm } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/DateQuestionForm";
|
||||
import { FileUploadQuestionForm } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/FileUploadQuestionForm";
|
||||
import { MatrixQuestionForm } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/MatrixQuestionForm";
|
||||
import { MultipleChoiceMultiForm } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/MultipleChoiceMultiForm";
|
||||
import { MultipleChoiceSingleForm } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/MultipleChoiceSingleForm";
|
||||
import { NPSQuestionForm } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/NPSQuestionForm";
|
||||
import { OpenQuestionForm } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/OpenQuestionForm";
|
||||
import { PictureSelectionForm } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/PictureSelectionForm";
|
||||
import { RatingQuestionForm } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/RatingQuestionForm";
|
||||
import { getTSurveyQuestionTypeName } from "@/app/lib/questions";
|
||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
import {
|
||||
@@ -44,7 +30,21 @@ import { Label } from "@formbricks/ui/Label";
|
||||
import { QuestionFormInput } from "@formbricks/ui/QuestionFormInput";
|
||||
import { Switch } from "@formbricks/ui/Switch";
|
||||
|
||||
import QuestionDropdown from "./QuestionMenu";
|
||||
import { AddressQuestionForm } from "./AddressQuestionForm";
|
||||
import { AdvancedSettings } from "./AdvancedSettings";
|
||||
import { CTAQuestionForm } from "./CTAQuestionForm";
|
||||
import { CalQuestionForm } from "./CalQuestionForm";
|
||||
import { ConsentQuestionForm } from "./ConsentQuestionForm";
|
||||
import { DateQuestionForm } from "./DateQuestionForm";
|
||||
import { FileUploadQuestionForm } from "./FileUploadQuestionForm";
|
||||
import { MatrixQuestionForm } from "./MatrixQuestionForm";
|
||||
import { MultipleChoiceMultiForm } from "./MultipleChoiceMultiForm";
|
||||
import { MultipleChoiceSingleForm } from "./MultipleChoiceSingleForm";
|
||||
import { NPSQuestionForm } from "./NPSQuestionForm";
|
||||
import { OpenQuestionForm } from "./OpenQuestionForm";
|
||||
import { PictureSelectionForm } from "./PictureSelectionForm";
|
||||
import { QuestionDropdown } from "./QuestionMenu";
|
||||
import { RatingQuestionForm } from "./RatingQuestionForm";
|
||||
|
||||
interface QuestionCardProps {
|
||||
localSurvey: TSurvey;
|
||||
@@ -10,13 +10,13 @@ interface QuestionDropdownProps {
|
||||
moveQuestion: (questionIdx: number, up: boolean) => void;
|
||||
}
|
||||
|
||||
export default function QuestionActions({
|
||||
export const QuestionDropdown = ({
|
||||
questionIdx,
|
||||
lastQuestion,
|
||||
duplicateQuestion,
|
||||
deleteQuestion,
|
||||
moveQuestion,
|
||||
}: QuestionDropdownProps) {
|
||||
}: QuestionDropdownProps) => {
|
||||
return (
|
||||
<div className="flex space-x-2">
|
||||
<ArrowUpIcon
|
||||
@@ -57,4 +57,4 @@ export default function QuestionActions({
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -1,11 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import HiddenFieldsCard from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/HiddenFieldsCard";
|
||||
import {
|
||||
isCardValid,
|
||||
validateQuestion,
|
||||
validateSurveyQuestionsInBatch,
|
||||
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/lib/validation";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { DragDropContext } from "react-beautiful-dnd";
|
||||
@@ -18,9 +12,11 @@ import { checkForEmptyFallBackValue, extractRecallInfo } from "@formbricks/lib/u
|
||||
import { TProduct } from "@formbricks/types/product";
|
||||
import { TSurvey, TSurveyQuestion } from "@formbricks/types/surveys";
|
||||
|
||||
import { isCardValid, validateQuestion, validateSurveyQuestionsInBatch } from "../lib/validation";
|
||||
import AddQuestionButton from "./AddQuestionButton";
|
||||
import EditThankYouCard from "./EditThankYouCard";
|
||||
import EditWelcomeCard from "./EditWelcomeCard";
|
||||
import HiddenFieldsCard from "./HiddenFieldsCard";
|
||||
import QuestionCard from "./QuestionCard";
|
||||
import { StrictModeDroppable } from "./StrictModeDroppable";
|
||||
|
||||
@@ -148,7 +148,7 @@ export default function RecontactOptionsCard({
|
||||
This setting overwrites your{" "}
|
||||
<Link
|
||||
className="decoration-brand-dark underline"
|
||||
href={`/environments/${environmentId}/settings/product`}
|
||||
href={`/environments/${environmentId}/product/general`}
|
||||
target="_blank">
|
||||
waiting period
|
||||
</Link>
|
||||
@@ -0,0 +1,90 @@
|
||||
import { Code2Icon, MousePointerClickIcon, SparklesIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { TActionClass } from "@formbricks/types/actionClasses";
|
||||
import { TSurvey } from "@formbricks/types/surveys";
|
||||
import { Input } from "@formbricks/ui/Input";
|
||||
|
||||
interface SavedActionsTabProps {
|
||||
actionClasses: TActionClass[];
|
||||
localSurvey: TSurvey;
|
||||
setLocalSurvey: React.Dispatch<React.SetStateAction<TSurvey>>;
|
||||
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
export const SavedActionsTab = ({
|
||||
actionClasses,
|
||||
localSurvey,
|
||||
setLocalSurvey,
|
||||
setOpen,
|
||||
}: SavedActionsTabProps) => {
|
||||
const availableActions = actionClasses.filter(
|
||||
(actionClass) => !localSurvey.triggers.some((trigger) => trigger.actionClass.id === actionClass.id)
|
||||
);
|
||||
const [filteredActionClasses, setFilteredActionClasses] = useState<TActionClass[]>(availableActions);
|
||||
|
||||
const codeActions = filteredActionClasses.filter((actionClass) => actionClass.type === "code");
|
||||
const noCodeActions = filteredActionClasses.filter((actionClass) => actionClass.type === "noCode");
|
||||
const automaticActions = filteredActionClasses.filter((actionClass) => actionClass.type === "automatic");
|
||||
|
||||
const handleActionClick = (action: TActionClass) => {
|
||||
setLocalSurvey((prev) => ({
|
||||
...prev,
|
||||
triggers: prev.triggers.concat({ actionClass: action }),
|
||||
}));
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Input
|
||||
type="text"
|
||||
onChange={(e) => {
|
||||
setFilteredActionClasses(
|
||||
availableActions.filter((actionClass) =>
|
||||
actionClass.name.toLowerCase().includes(e.target.value.toLowerCase())
|
||||
)
|
||||
);
|
||||
}}
|
||||
className="mb-2 bg-white"
|
||||
placeholder="Search actions"
|
||||
id="search-actions"
|
||||
/>
|
||||
<div className="max-h-96 overflow-y-auto">
|
||||
{[automaticActions, noCodeActions, codeActions].map(
|
||||
(actions, i) =>
|
||||
actions.length > 0 && (
|
||||
<div key={i} className="me-4">
|
||||
<h2 className="mb-2 mt-4 font-semibold">
|
||||
{i === 0 ? "Automatic" : i === 1 ? "No code" : "Code"}
|
||||
</h2>
|
||||
<div className="flex flex-col gap-2">
|
||||
{actions.map((action) => (
|
||||
<div
|
||||
key={action.id}
|
||||
className="cursor-pointer rounded-md border border-slate-300 bg-white px-4 py-2 hover:bg-slate-100"
|
||||
onClick={() => handleActionClick(action)}>
|
||||
<div className="mt-1 flex items-center">
|
||||
<div className="mr-1.5 h-4 w-4 text-slate-600">
|
||||
{action.type === "code" ? (
|
||||
<Code2Icon className="h-4 w-4" />
|
||||
) : action.type === "noCode" ? (
|
||||
<MousePointerClickIcon className="h-4 w-4" />
|
||||
) : action.type === "automatic" ? (
|
||||
<SparklesIcon className="h-4 w-4" />
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<h4 className="text-sm font-semibold text-slate-600">{action.name}</h4>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-gray-500">{action.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,3 @@
|
||||
import SurveyPlacementCard from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/SurveyPlacementCard";
|
||||
|
||||
import { AdvancedTargetingCard } from "@formbricks/ee/advancedTargeting/components/AdvancedTargetingCard";
|
||||
import { TActionClass } from "@formbricks/types/actionClasses";
|
||||
import { TAttributeClass } from "@formbricks/types/attributeClasses";
|
||||
@@ -11,6 +9,7 @@ import { TSurvey } from "@formbricks/types/surveys";
|
||||
import HowToSendCard from "./HowToSendCard";
|
||||
import RecontactOptionsCard from "./RecontactOptionsCard";
|
||||
import ResponseOptionsCard from "./ResponseOptionsCard";
|
||||
import SurveyPlacementCard from "./SurveyPlacementCard";
|
||||
import TargetingCard from "./TargetingCard";
|
||||
import WhenToSendCard from "./WhenToSendCard";
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
import BackgroundStylingCard from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/BackgroundStylingCard";
|
||||
import CardStylingSettings from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/CardStylingSettings";
|
||||
import FormStylingSettings from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/FormStylingSettings";
|
||||
import { RotateCcwIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
@@ -13,6 +10,10 @@ import { AlertDialog } from "@formbricks/ui/AlertDialog";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { Switch } from "@formbricks/ui/Switch";
|
||||
|
||||
import { BackgroundStylingCard } from "./BackgroundStylingCard";
|
||||
import { CardStylingSettings } from "./CardStylingSettings";
|
||||
import { FormStylingSettings } from "./FormStylingSettings";
|
||||
|
||||
type StylingViewProps = {
|
||||
environment: TEnvironment;
|
||||
product: TProduct;
|
||||
@@ -1,13 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { refetchProduct } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/actions";
|
||||
import { LoadingSkeleton } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/LoadingSkeleton";
|
||||
import { QuestionsAudienceTabs } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionsStylingSettingsTabs";
|
||||
import { QuestionsView } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/QuestionsView";
|
||||
import { SettingsView } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/SettingsView";
|
||||
import { StylingView } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/StylingView";
|
||||
import { SurveyMenuBar } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/SurveyMenuBar";
|
||||
import { PreviewSurvey } from "@/app/(app)/environments/[environmentId]/surveys/components/PreviewSurvey";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { extractLanguageCodes, getEnabledLanguages } from "@formbricks/lib/i18n/utils";
|
||||
@@ -20,6 +12,15 @@ import { TMembershipRole } from "@formbricks/types/memberships";
|
||||
import { TProduct } from "@formbricks/types/product";
|
||||
import { TSegment } from "@formbricks/types/segment";
|
||||
import { TSurvey, TSurveyEditorTabs, TSurveyStyling } from "@formbricks/types/surveys";
|
||||
import { PreviewSurvey } from "@formbricks/ui/PreviewSurvey";
|
||||
|
||||
import { refetchProductAction } from "../actions";
|
||||
import { LoadingSkeleton } from "./LoadingSkeleton";
|
||||
import { QuestionsAudienceTabs } from "./QuestionsStylingSettingsTabs";
|
||||
import { QuestionsView } from "./QuestionsView";
|
||||
import { SettingsView } from "./SettingsView";
|
||||
import { StylingView } from "./StylingView";
|
||||
import { SurveyMenuBar } from "./SurveyMenuBar";
|
||||
|
||||
interface SurveyEditorProps {
|
||||
survey: TSurvey;
|
||||
@@ -64,7 +65,7 @@ export default function SurveyEditor({
|
||||
const [localStylingChanges, setLocalStylingChanges] = useState<TSurveyStyling | null>(null);
|
||||
|
||||
const fetchLatestProduct = useCallback(async () => {
|
||||
const latestProduct = await refetchProduct(localProduct.id);
|
||||
const latestProduct = await refetchProductAction(localProduct.id);
|
||||
if (latestProduct) {
|
||||
setLocalProduct(latestProduct);
|
||||
}
|
||||
@@ -91,7 +92,7 @@ export default function SurveyEditor({
|
||||
const listener = () => {
|
||||
if (document.visibilityState === "visible") {
|
||||
const fetchLatestProduct = async () => {
|
||||
const latestProduct = await refetchProduct(localProduct.id);
|
||||
const latestProduct = await refetchProductAction(localProduct.id);
|
||||
if (latestProduct) {
|
||||
setLocalProduct(latestProduct);
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { SurveyStatusDropdown } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/SurveyStatusDropdown";
|
||||
import { isSurveyValid } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/lib/validation";
|
||||
import { isEqual } from "lodash";
|
||||
import { AlertTriangleIcon, ArrowLeftIcon, SettingsIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
@@ -19,6 +18,7 @@ import { Input } from "@formbricks/ui/Input";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@formbricks/ui/Tooltip";
|
||||
|
||||
import { updateSurveyAction } from "../actions";
|
||||
import { isSurveyValid } from "../lib/validation";
|
||||
|
||||
interface SurveyMenuBarProps {
|
||||
localSurvey: TSurvey;
|
||||
@@ -53,7 +53,7 @@ export const SurveyMenuBar = ({
|
||||
const [isConfirmDialogOpen, setConfirmDialogOpen] = useState(false);
|
||||
const [isSurveyPublishing, setIsSurveyPublishing] = useState(false);
|
||||
const [isSurveySaving, setIsSurveySaving] = useState(false);
|
||||
const cautionText = "This survey received responses, make changes with caution.";
|
||||
const cautionText = "This survey received responses.";
|
||||
|
||||
const faultyQuestions: string[] = [];
|
||||
|
||||
@@ -86,13 +86,8 @@ export const SurveyMenuBar = ({
|
||||
if (localSurvey.type === "link") return false;
|
||||
|
||||
const noTriggers = !localSurvey.triggers || localSurvey.triggers.length === 0 || !localSurvey.triggers[0];
|
||||
const noInlineTriggers =
|
||||
!localSurvey.inlineTriggers ||
|
||||
(!localSurvey.inlineTriggers?.codeConfig && !localSurvey.inlineTriggers?.noCodeConfig);
|
||||
|
||||
if (noTriggers && noInlineTriggers) {
|
||||
return true;
|
||||
}
|
||||
if (noTriggers) return true;
|
||||
|
||||
return false;
|
||||
}, [localSurvey]);
|
||||
@@ -113,7 +108,6 @@ export const SurveyMenuBar = ({
|
||||
const handleBack = () => {
|
||||
const { updatedAt, ...localSurveyRest } = localSurvey;
|
||||
const { updatedAt: _, ...surveyRest } = survey;
|
||||
localSurveyRest.triggers = localSurveyRest.triggers.filter((trigger) => Boolean(trigger));
|
||||
|
||||
if (!isEqual(localSurveyRest, surveyRest)) {
|
||||
setConfirmDialogOpen(true);
|
||||
@@ -163,7 +157,7 @@ export const SurveyMenuBar = ({
|
||||
setIsSurveySaving(false);
|
||||
return;
|
||||
}
|
||||
localSurvey.triggers = localSurvey.triggers.filter((trigger) => Boolean(trigger));
|
||||
|
||||
localSurvey.questions = localSurvey.questions.map((question) => {
|
||||
const { isDraft, ...rest } = question;
|
||||
return rest;
|
||||
@@ -222,13 +216,6 @@ export const SurveyMenuBar = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
{environment?.type === "development" && (
|
||||
<nav className="top-0 z-10 w-full border-b border-slate-200 bg-white">
|
||||
<div className="h-6 w-full bg-[#A33700] p-0.5 text-center text-sm text-white">
|
||||
You're in development mode. Use it to test surveys, actions and attributes.
|
||||
</div>
|
||||
</nav>
|
||||
)}
|
||||
<div className="border-b border-slate-200 bg-white px-5 py-3 sm:flex sm:items-center sm:justify-between">
|
||||
<div className="flex items-center space-x-2 whitespace-nowrap">
|
||||
<Button
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import Placement from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/Placement";
|
||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
import { CheckIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
@@ -11,6 +10,8 @@ import { TSurvey, TSurveyProductOverwrites } from "@formbricks/types/surveys";
|
||||
import { Label } from "@formbricks/ui/Label";
|
||||
import { Switch } from "@formbricks/ui/Switch";
|
||||
|
||||
import Placement from "./Placement";
|
||||
|
||||
interface SurveyPlacementCardProps {
|
||||
localSurvey: TSurvey;
|
||||
setLocalSurvey: (survey: TSurvey) => void;
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { validateId } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/lib/validation";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
@@ -9,6 +8,8 @@ import { Button } from "@formbricks/ui/Button";
|
||||
import { Input } from "@formbricks/ui/Input";
|
||||
import { Label } from "@formbricks/ui/Label";
|
||||
|
||||
import { validateId } from "../lib/validation";
|
||||
|
||||
interface UpdateQuestionIdProps {
|
||||
localSurvey: TSurvey;
|
||||
question: TSurveyQuestion;
|
||||
@@ -1,10 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import AddNoCodeActionModal from "@/app/(app)/environments/[environmentId]/actions/components/AddActionModal";
|
||||
import InlineTriggers from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/InlineTriggers";
|
||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
import { CheckIcon, PlusIcon, TrashIcon } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
CheckIcon,
|
||||
Code2Icon,
|
||||
MousePointerClickIcon,
|
||||
PlusIcon,
|
||||
SparklesIcon,
|
||||
Trash2Icon,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { getAccessFlags } from "@formbricks/lib/membership/utils";
|
||||
import { TActionClass } from "@formbricks/types/actionClasses";
|
||||
@@ -13,15 +18,8 @@ import { TSurvey } from "@formbricks/types/surveys";
|
||||
import { AdvancedOptionToggle } from "@formbricks/ui/AdvancedOptionToggle";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { Input } from "@formbricks/ui/Input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@formbricks/ui/Select";
|
||||
import { TabBar } from "@formbricks/ui/TabBar";
|
||||
|
||||
import { AddActionModal } from "./AddActionModal";
|
||||
|
||||
interface WhenToSendCardProps {
|
||||
localSurvey: TSurvey;
|
||||
@@ -41,52 +39,16 @@ export default function WhenToSendCard({
|
||||
const [open, setOpen] = useState(
|
||||
localSurvey.type === "app" || localSurvey.type === "website" ? true : false
|
||||
);
|
||||
const [isAddEventModalOpen, setAddEventModalOpen] = useState(false);
|
||||
const [activeIndex, setActiveIndex] = useState<number | null>(null);
|
||||
const [isAddActionModalOpen, setAddActionModalOpen] = useState(false);
|
||||
const [actionClasses, setActionClasses] = useState<TActionClass[]>(propActionClasses);
|
||||
const [randomizerToggle, setRandomizerToggle] = useState(localSurvey.displayPercentage ? true : false);
|
||||
|
||||
const [activeTriggerTab, setActiveTriggerTab] = useState(
|
||||
!!localSurvey?.inlineTriggers ? "inline" : "relation"
|
||||
);
|
||||
const tabs = [
|
||||
{
|
||||
id: "relation",
|
||||
label: "Saved Actions",
|
||||
},
|
||||
{
|
||||
id: "inline",
|
||||
label: "Custom Actions",
|
||||
},
|
||||
];
|
||||
|
||||
const { isViewer } = getAccessFlags(membershipRole);
|
||||
|
||||
const autoClose = localSurvey.autoClose !== null;
|
||||
const delay = localSurvey.delay !== 0;
|
||||
|
||||
const addTriggerEvent = useCallback(() => {
|
||||
const updatedSurvey = { ...localSurvey };
|
||||
updatedSurvey.triggers = [...localSurvey.triggers, ""];
|
||||
setLocalSurvey(updatedSurvey);
|
||||
}, [localSurvey, setLocalSurvey]);
|
||||
|
||||
const setTriggerEvent = useCallback(
|
||||
(idx: number, actionClassName: string) => {
|
||||
const updatedSurvey = { ...localSurvey };
|
||||
const newActionClass = actionClasses!.find((actionClass) => {
|
||||
return actionClass.name === actionClassName;
|
||||
});
|
||||
if (!newActionClass) {
|
||||
throw new Error("Action class not found");
|
||||
}
|
||||
updatedSurvey.triggers[idx] = newActionClass.name;
|
||||
setLocalSurvey(updatedSurvey);
|
||||
},
|
||||
[actionClasses, localSurvey, setLocalSurvey]
|
||||
);
|
||||
|
||||
const removeTriggerEvent = (idx: number) => {
|
||||
const handleRemoveTriggerEvent = (idx: number) => {
|
||||
const updatedSurvey = { ...localSurvey };
|
||||
updatedSurvey.triggers = [...localSurvey.triggers.slice(0, idx), ...localSurvey.triggers.slice(idx + 1)];
|
||||
setLocalSurvey(updatedSurvey);
|
||||
@@ -143,60 +105,16 @@ export default function WhenToSendCard({
|
||||
setLocalSurvey(updatedSurvey);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isAddEventModalOpen) return;
|
||||
|
||||
if (activeIndex !== null) {
|
||||
const newActionClass = actionClasses[actionClasses.length - 1].name;
|
||||
const currentActionClass = localSurvey.triggers[activeIndex];
|
||||
|
||||
if (newActionClass !== currentActionClass) {
|
||||
setTriggerEvent(activeIndex, newActionClass);
|
||||
}
|
||||
|
||||
setActiveIndex(null);
|
||||
}
|
||||
}, [actionClasses, activeIndex, setTriggerEvent, isAddEventModalOpen, localSurvey.triggers]);
|
||||
|
||||
useEffect(() => {
|
||||
if (localSurvey.type === "link") {
|
||||
setOpen(false);
|
||||
}
|
||||
}, [localSurvey.type]);
|
||||
|
||||
//create new empty trigger on page load, remove one click for user
|
||||
useEffect(() => {
|
||||
if (localSurvey.triggers.length === 0) {
|
||||
addTriggerEvent();
|
||||
}
|
||||
}, [addTriggerEvent, localSurvey.triggers.length]);
|
||||
|
||||
const containsEmptyTriggers = useMemo(() => {
|
||||
const noTriggers = !localSurvey.triggers || !localSurvey.triggers.length || !localSurvey.triggers[0];
|
||||
const noInlineTriggers =
|
||||
!localSurvey.inlineTriggers ||
|
||||
(!localSurvey.inlineTriggers?.codeConfig && !localSurvey.inlineTriggers?.noCodeConfig);
|
||||
|
||||
if (noTriggers && noInlineTriggers) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
return !localSurvey.triggers || !localSurvey.triggers.length || !localSurvey.triggers[0];
|
||||
}, [localSurvey]);
|
||||
|
||||
// for inline triggers, if both the codeConfig and noCodeConfig are empty, we consider it as empty
|
||||
useEffect(() => {
|
||||
const inlineTriggers = localSurvey?.inlineTriggers ?? {};
|
||||
if (Object.keys(inlineTriggers).length === 0) {
|
||||
setLocalSurvey((prevSurvey) => {
|
||||
return {
|
||||
...prevSurvey,
|
||||
inlineTriggers: null,
|
||||
};
|
||||
});
|
||||
}
|
||||
}, [localSurvey?.inlineTriggers, setLocalSurvey]);
|
||||
|
||||
if (localSurvey.type === "link") {
|
||||
return null; // Hide card completely
|
||||
}
|
||||
@@ -213,7 +131,8 @@ export default function WhenToSendCard({
|
||||
className="w-full rounded-lg border border-slate-300 bg-white">
|
||||
<Collapsible.CollapsibleTrigger
|
||||
asChild
|
||||
className="h-full w-full cursor-pointer rounded-lg hover:bg-slate-50">
|
||||
className="h-full w-full cursor-pointer rounded-lg hover:bg-slate-50"
|
||||
id="whenToSendCardTrigger">
|
||||
<div className="inline-flex px-4 py-4">
|
||||
<div className="flex items-center pl-2 pr-5">
|
||||
{containsEmptyTriggers ? (
|
||||
@@ -237,77 +156,85 @@ export default function WhenToSendCard({
|
||||
<hr className="py-1 text-slate-600" />
|
||||
|
||||
<div className="px-3 pb-3 pt-1">
|
||||
<div className="flex flex-col overflow-hidden rounded-lg border-2 border-slate-100">
|
||||
<TabBar
|
||||
tabs={tabs}
|
||||
activeId={activeTriggerTab}
|
||||
setActiveId={setActiveTriggerTab}
|
||||
tabStyle="button"
|
||||
className="bg-slate-100"
|
||||
/>
|
||||
<div className="p-3">
|
||||
{activeTriggerTab === "inline" ? (
|
||||
<div className="flex flex-col">
|
||||
<InlineTriggers localSurvey={localSurvey} setLocalSurvey={setLocalSurvey} />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{!isAddEventModalOpen &&
|
||||
localSurvey.triggers?.map((triggerEventClass, idx) => (
|
||||
<div className="mt-2" key={idx}>
|
||||
<div className="inline-flex items-center">
|
||||
<p className="mr-2 w-14 text-right text-sm">{idx === 0 ? "When" : "or"}</p>
|
||||
<Select
|
||||
value={triggerEventClass}
|
||||
onValueChange={(actionClassName) => setTriggerEvent(idx, actionClassName)}>
|
||||
<SelectTrigger className="w-[240px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center space-x-2 rounded-md p-1 text-sm font-semibold text-slate-800 hover:bg-slate-100 hover:text-slate-500"
|
||||
value="none"
|
||||
onClick={() => {
|
||||
setAddEventModalOpen(true);
|
||||
setActiveIndex(idx);
|
||||
}}>
|
||||
<PlusIcon className="mr-1 h-5 w-5" />
|
||||
Add Action
|
||||
</button>
|
||||
<SelectSeparator />
|
||||
{actionClasses.map((actionClass) => (
|
||||
<SelectItem
|
||||
value={actionClass.name}
|
||||
key={actionClass.name}
|
||||
title={actionClass.description ? actionClass.description : ""}>
|
||||
{actionClass.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="mx-2 text-sm">action is performed</p>
|
||||
<button type="button" onClick={() => removeTriggerEvent(idx)}>
|
||||
<TrashIcon className="ml-3 h-4 w-4 text-slate-400" />
|
||||
</button>
|
||||
<div className="filter-scrollbar flex flex-col gap-4 overflow-auto rounded-lg border border-slate-300 bg-slate-50 p-4">
|
||||
<p className="text-sm font-semibold text-slate-800">
|
||||
Trigger survey when one of the actions is fired...
|
||||
</p>
|
||||
|
||||
{localSurvey.triggers.filter(Boolean).map((trigger, idx) => {
|
||||
return (
|
||||
<div className="flex items-center gap-2" key={trigger.actionClass.id}>
|
||||
{idx !== 0 && <p className="ml-1 text-sm font-bold text-slate-700">or</p>}
|
||||
<div
|
||||
key={trigger.actionClass.id}
|
||||
className="flex grow items-center justify-between rounded-md border border-slate-300 bg-white p-2 px-3">
|
||||
<div>
|
||||
<div className="mt-1 flex items-center">
|
||||
<div className="mr-1.5 h-4 w-4 text-slate-600">
|
||||
{trigger.actionClass.type === "code" ? (
|
||||
<Code2Icon className="h-4 w-4" />
|
||||
) : trigger.actionClass.type === "noCode" ? (
|
||||
<MousePointerClickIcon className="h-4 w-4" />
|
||||
) : trigger.actionClass.type === "automatic" ? (
|
||||
<SparklesIcon className="h-4 w-4" />
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<h4 className="text-sm font-semibold text-slate-600">{trigger.actionClass.name}</h4>
|
||||
</div>
|
||||
))}
|
||||
<div className="px-6 py-4">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
addTriggerEvent();
|
||||
}}>
|
||||
<PlusIcon className="mr-2 h-4 w-4" />
|
||||
Add condition
|
||||
</Button>
|
||||
<div className="mt-1 text-xs text-gray-500">
|
||||
{trigger.actionClass.description && (
|
||||
<span className="mr-1">{trigger.actionClass.description}</span>
|
||||
)}
|
||||
{trigger.actionClass.type === "code" && (
|
||||
<span className="mr-1 border-l border-slate-400 pl-1 first:border-l-0 first:pl-0">
|
||||
Key: <b>{trigger.actionClass.key}</b>
|
||||
</span>
|
||||
)}
|
||||
{trigger.actionClass.type === "noCode" &&
|
||||
trigger.actionClass.noCodeConfig?.cssSelector && (
|
||||
<span className="mr-1 border-l border-slate-400 pl-1 first:border-l-0 first:pl-0">
|
||||
CSS Selector: <b>{trigger.actionClass.noCodeConfig.cssSelector.value}</b>
|
||||
</span>
|
||||
)}
|
||||
{trigger.actionClass.type === "noCode" &&
|
||||
trigger.actionClass.noCodeConfig?.innerHtml && (
|
||||
<span className="mr-1 border-l border-slate-400 pl-1 first:border-l-0 first:pl-0">
|
||||
Inner Text: <b>{trigger.actionClass.noCodeConfig.innerHtml.value}</b>
|
||||
</span>
|
||||
)}
|
||||
{trigger.actionClass.type === "noCode" &&
|
||||
trigger.actionClass.noCodeConfig?.pageUrl && (
|
||||
<span className="mr-1 border-l border-slate-400 pl-1 first:border-l-0 first:pl-0">
|
||||
URL {trigger.actionClass.noCodeConfig.pageUrl.rule}:{" "}
|
||||
<b>{trigger.actionClass.noCodeConfig.pageUrl.value}</b>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<Trash2Icon
|
||||
className="h-4 w-4 cursor-pointer text-slate-600"
|
||||
onClick={() => handleRemoveTriggerEvent(idx)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setAddActionModalOpen(true);
|
||||
}}>
|
||||
<PlusIcon className="mr-2 h-4 w-4" />
|
||||
Add action
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Survey Display Settings */}
|
||||
<div className="mb-4 mt-8 space-y-1 px-4">
|
||||
<h3 className="font-semibold text-slate-800">Survey Display Settings</h3>
|
||||
<p className="text-sm text-slate-500">Add a delay or auto-close the survey</p>
|
||||
@@ -387,13 +314,15 @@ export default function WhenToSendCard({
|
||||
</div>
|
||||
</Collapsible.CollapsibleContent>
|
||||
</Collapsible.Root>
|
||||
<AddNoCodeActionModal
|
||||
<AddActionModal
|
||||
environmentId={environmentId}
|
||||
open={isAddEventModalOpen}
|
||||
setOpen={setAddEventModalOpen}
|
||||
open={isAddActionModalOpen}
|
||||
setOpen={setAddActionModalOpen}
|
||||
actionClasses={actionClasses}
|
||||
setActionClasses={setActionClasses}
|
||||
isViewer={isViewer}
|
||||
localSurvey={localSurvey}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
@@ -21,8 +21,6 @@ import {
|
||||
TSurveyQuestions,
|
||||
TSurveyThankYouCard,
|
||||
TSurveyWelcomeCard,
|
||||
ZSurveyInlineTriggers,
|
||||
surveyHasBothTriggers,
|
||||
} from "@formbricks/types/surveys";
|
||||
|
||||
// Utility function to check if label is valid for all required languages
|
||||
@@ -451,21 +449,6 @@ export const isSurveyValid = (
|
||||
}
|
||||
}
|
||||
|
||||
// if inlineTriggers are present validate with zod
|
||||
if (!!survey.inlineTriggers) {
|
||||
const parsedInlineTriggers = ZSurveyInlineTriggers.safeParse(survey.inlineTriggers);
|
||||
if (!parsedInlineTriggers.success) {
|
||||
toast.error("Invalid Custom Actions: Please check your custom actions");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// validate that both triggers and inlineTriggers are not present
|
||||
if (surveyHasBothTriggers(survey)) {
|
||||
toast.error("Survey cannot have both custom and saved actions, please remove one.");
|
||||
return false;
|
||||
}
|
||||
|
||||
const questionWithEmptyFallback = checkForEmptyFallBackValue(survey, selectedLanguageCode);
|
||||
if (questionWithEmptyFallback) {
|
||||
toast.error("Fallback missing");
|
||||
@@ -0,0 +1,5 @@
|
||||
import { LoadingSkeleton } from "./components/LoadingSkeleton";
|
||||
|
||||
export default function Loading() {
|
||||
return <LoadingSkeleton />;
|
||||
}
|
||||
@@ -59,8 +59,8 @@ export default async function SurveysEditPage({ params }) {
|
||||
const { isViewer } = getAccessFlags(currentUserMembership?.role);
|
||||
const isSurveyCreationDeletionDisabled = isViewer;
|
||||
|
||||
const isUserTargetingAllowed = getAdvancedTargetingPermission(team);
|
||||
const isMultiLanguageAllowed = getMultiLanguagePermission(team);
|
||||
const isUserTargetingAllowed = await getAdvancedTargetingPermission(team);
|
||||
const isMultiLanguageAllowed = await getMultiLanguagePermission(team);
|
||||
|
||||
if (
|
||||
!survey ||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { TSurvey } from "@formbricks/types/surveys";
|
||||
|
||||
export const minimalSurvey: TSurvey = {
|
||||
id: "someUniqueId1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
name: "Minimal Survey",
|
||||
type: "app",
|
||||
environmentId: "someEnvId1",
|
||||
createdBy: null,
|
||||
status: "draft",
|
||||
displayOption: "displayOnce",
|
||||
autoClose: null,
|
||||
triggers: [],
|
||||
redirectUrl: null,
|
||||
recontactDays: null,
|
||||
welcomeCard: {
|
||||
enabled: false,
|
||||
headline: { default: "Welcome!" },
|
||||
html: { default: "Thanks for providing your feedback - let's go!" },
|
||||
timeToFinish: false,
|
||||
showResponseCount: false,
|
||||
},
|
||||
questions: [],
|
||||
thankYouCard: {
|
||||
enabled: false,
|
||||
},
|
||||
hiddenFields: {
|
||||
enabled: false,
|
||||
},
|
||||
delay: 0, // No delay
|
||||
displayPercentage: null,
|
||||
autoComplete: null,
|
||||
runOnDate: null,
|
||||
closeOnDate: null,
|
||||
surveyClosedMessage: {
|
||||
enabled: false,
|
||||
},
|
||||
productOverwrites: null,
|
||||
singleUse: null,
|
||||
styling: null,
|
||||
resultShareKey: null,
|
||||
segment: null,
|
||||
languages: [],
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowLeftIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
|
||||
export const BackButton = () => {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<Button
|
||||
variant="secondary"
|
||||
StartIcon={ArrowLeftIcon}
|
||||
onClick={() => {
|
||||
router.back();
|
||||
}}>
|
||||
Back
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { BackButton } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/templates/components/BackButton";
|
||||
|
||||
export const MenuBar = () => {
|
||||
return (
|
||||
<>
|
||||
<div className="border-b border-slate-200 bg-white px-5 py-3 sm:flex sm:items-center sm:justify-between">
|
||||
<div className="flex items-center space-x-2 whitespace-nowrap">
|
||||
<BackButton />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user