Compare commits

...

35 Commits

Author SHA1 Message Date
Anshuman Pandey
8aedbde36a fix: 2.2 data migration for self-hosters (#2792) 2024-06-20 14:43:26 +02:00
Piyush Gupta
17279cb3fe fix: null channel survey type bug (#2788) 2024-06-20 12:55:06 +02:00
Piyush Gupta
1b4823421f fix: google translate issue (#2787) 2024-06-20 10:32:36 +00:00
Piyush Gupta
deef604325 feat: adds functionality limit based on channel (#2772)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
Co-authored-by: Johannes <johannes@formbricks.com>
2024-06-20 09:16:59 +02:00
Johannes
22e44b47e6 fix: orange css for offer price (#2786) 2024-06-20 06:39:29 +00:00
Johannes
08052b13cf chore: add launch and pricing ui tweaks (#2782)
Co-authored-by: Matti Nannt <mail@matthiasnannt.com>
2024-06-20 05:54:23 +00:00
Matti Nannt
5e7fe96ef6 chore: prepare 2.2 launch (#2783) 2024-06-19 17:16:57 +02:00
Piyush Gupta
83ff98cd3a fix: Ability to increase file size limit (#2734)
Co-authored-by: Dhruwang Jariwala <67850763+Dhruwang@users.noreply.github.com>
2024-06-19 14:49:16 +00:00
Pranoy Roy
eb54230445 feat: added in-page section navigation for docs (#2720)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: Matti Nannt <mail@matthiasnannt.com>
2024-06-19 14:23:51 +00:00
Dhruwang Jariwala
59d57c50eb fix: editor text color (#2781) 2024-06-19 12:31:46 +00:00
Dhruwang Jariwala
f358254e3c feat: Product onboarding with XM approach (#2770)
Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2024-06-19 12:29:05 +00:00
Dhruwang Jariwala
3ab8092a82 fix: Loading indicator for question card images (#2778)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2024-06-19 12:04:27 +00:00
Anshuman Pandey
3b08b718ff feat: unlimited billing pages (#2777)
Co-authored-by: Matti Nannt <mail@matthiasnannt.com>
2024-06-19 08:44:45 +00:00
Dhruwang Jariwala
a473719eee feat: XM template filters (#2745)
Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: Matti Nannt <mail@matthiasnannt.com>
2024-06-19 08:19:45 +00:00
Dhruwang Jariwala
cd40e655fb fix: progress bar issue (#2771)
Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
2024-06-19 08:04:11 +00:00
Dhruwang Jariwala
5d468b4420 fix: Adjust billing permissions (#2775)
Co-authored-by: Matti Nannt <mail@matthiasnannt.com>
2024-06-19 07:23:28 +00:00
Piyush Gupta
96806c613f fix: fixes notification subscription based on user membership (#2779) 2024-06-19 07:22:05 +00:00
Piyush Gupta
6f2a4b2b03 feat: Full RTL support for Multi-Language (#2727)
Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2024-06-19 07:10:28 +00:00
Piyush Gupta
d33efa0274 fix: e2e tests (#2780) 2024-06-19 06:34:16 +00:00
Matthias Nannt
9b35ebc114 fix: re-activate weekly summary cron job on Github Actions 2024-06-18 10:31:30 +02:00
Matthias Nannt
dba62157bf fix: temporary simplify getMonthlyActiveOrganizationPeopleCount to solve performance issues 2024-06-17 17:22:08 +02:00
Matthias Nannt
556184f442 fix: remove example survey creation to avoid having multiple example surveys in one environment 2024-06-17 16:48:35 +02:00
Matthias Nannt
c33532f952 chore: improve performance of getMonthlyActiveOrganizationPeopleCount 2024-06-17 15:26:49 +02:00
Piyush Gupta
8f61ceb4ea feat: adds separate status indicator for both app and website (#2747)
Co-authored-by: Johannes <johannes@formbricks.com>
2024-06-17 11:07:52 +00:00
Matthias Nannt
e19dcdc0c4 chore: improve performance of getMonthlyActiveOrganizationPeopleCount call 2024-06-17 12:07:33 +02:00
Piyush Gupta
65f977786d feat: add file size limit error message for FileUploadQuestionForm (#2769)
Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
2024-06-17 06:37:57 +00:00
Matthias Nannt
95c13fd9a9 chore: deprecate js/sync endpoint 2024-06-15 10:01:06 +02:00
Matthias Nannt
f8adf28df8 chore: refactor getMonthlyActiveOrganizationPeopleCount to optimize performance 2024-06-14 23:08:12 +02:00
Matthias Nannt
9d468379f2 chore: reintroduce getMonthlyActiveOrganizationPeopleCount 2024-06-14 22:27:26 +02:00
Matthias Nannt
8071e82390 fix: reintroduce getMonthlyOrganizationResponseCount 2024-06-14 22:05:57 +02:00
Matthias Nannt
3ce775ec05 fix: performance issues on Formbricks Cloud due to updated billing checks (hotfix) 2024-06-14 21:18:39 +02:00
Anshuman Pandey
1223a30127 fix: wip (#2774) 2024-06-14 17:32:28 +02:00
Anshuman Pandey
f3906cab55 fix: getPersonCount (#2773) 2024-06-14 16:45:08 +02:00
Shubham Palriwala
d33304e3ad feat: pricing & payments v2 for formbricks cloud (#2648)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2024-06-14 13:17:33 +00:00
Piyush Gupta
ebed950392 fix: product creation bug (#2768) 2024-06-14 05:12:48 +00:00
290 changed files with 7426 additions and 6445 deletions

View File

@@ -162,9 +162,6 @@ ENTERPRISE_LICENSE_KEY=
# DEFAULT_ORGANIZATION_ID=
# DEFAULT_ORGANIZATION_ROLE=admin
# set to 1 to skip onboarding for new users
# ONBOARDING_DISABLED=1
# Send new users to customer.io
# CUSTOMER_IO_API_KEY=
# CUSTOMER_IO_SITE_ID=

View File

@@ -1,24 +0,0 @@
name: Cron - Report usage to Stripe
on:
workflow_dispatch:
# "Scheduled workflows run on the latest commit on the default or base branch."
# — https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#schedule
# schedule:
# This will run the job at 20:00 UTC every day of every month.
# - cron: "0 20 * * *"
jobs:
cron-reportUsageToStripe:
env:
APP_URL: ${{ secrets.APP_URL }}
CRON_SECRET: ${{ secrets.CRON_SECRET }}
runs-on: ubuntu-latest
steps:
- name: cURL request
if: ${{ env.APP_URL && env.CRON_SECRET }}
run: |
curl ${{ env.APP_URL }}/api/cron/report-usage \
-X POST \
-H 'x-api-key: ${{ env.CRON_SECRET }}' \
-H 'Cache-Control: no-cache' \
--fail

View File

@@ -4,9 +4,9 @@ on:
workflow_dispatch:
# "Scheduled workflows run on the latest commit on the default or base branch."
# — https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#schedule
# schedule:
# Runs “At 08:00 on Monday.” (see https://crontab.guru)
# - cron: "0 8 * * 1"
schedule:
# Runs “At 08:00 on Monday.” (see https://crontab.guru)
- cron: "0 8 * * 1"
jobs:
cron-weeklySummary:
env:

View File

@@ -5,9 +5,9 @@ concurrency:
on:
workflow_dispatch:
push:
branches:
- main
#push:
# branches:
# - main
jobs:
Deploy:
@@ -57,7 +57,6 @@ jobs:
AIRTABLE_CLIENT_ID: ${{ secrets.AIRTABLE_CLIENT_ID }}
ENTERPRISE_LICENSE_KEY: ${{ secrets.ENTERPRISE_LICENSE_KEY }}
DEFAULT_ORGANIZATION_ID: ${{ vars.DEFAULT_ORGANIZATION_ID }}
ONBOARDING_DISABLED: ${{ vars.ONBOARDING_DISABLED }}
CUSTOMER_IO_API_KEY: ${{ secrets.CUSTOMER_IO_API_KEY }}
CUSTOMER_IO_SITE_ID: ${{ secrets.CUSTOMER_IO_SITE_ID }}
NEXT_PUBLIC_POSTHOG_API_KEY: ${{ vars.NEXT_PUBLIC_POSTHOG_API_KEY }}

View File

@@ -54,7 +54,6 @@ jobs:
AIRTABLE_CLIENT_ID: ${{ secrets.AIRTABLE_CLIENT_ID }}
ENTERPRISE_LICENSE_KEY: ${{ secrets.ENTERPRISE_LICENSE_KEY }}
DEFAULT_ORGANIZATION_ID: ${{ vars.DEFAULT_ORGANIZATION_ID }}
ONBOARDING_DISABLED: ${{ vars.ONBOARDING_DISABLED }}
CUSTOMER_IO_API_KEY: ${{ secrets.CUSTOMER_IO_API_KEY }}
CUSTOMER_IO_SITE_ID: ${{ secrets.CUSTOMER_IO_SITE_ID }}
NEXT_PUBLIC_POSTHOG_API_KEY: ${{ vars.NEXT_PUBLIC_POSTHOG_API_KEY }}

View File

@@ -1,9 +1,10 @@
name: Release Changesets
on:
push:
branches:
- main
workflow_dispatch:
#push:
# branches:
# - main
concurrency: ${{ github.workflow }}-${{ github.ref }}

View File

@@ -13,8 +13,8 @@
"dependencies": {
"@formbricks/js": "workspace:*",
"@formbricks/ui": "workspace:*",
"lucide-react": "^0.379.0",
"next": "14.2.3",
"lucide-react": "^0.395.0",
"next": "14.2.4",
"react": "18.3.1",
"react-dom": "18.3.1"
},

View File

@@ -22,9 +22,7 @@ export const metadata = {
App surveys have 6-10x better conversion rates than emailed out surveys. This tutorial explains how to run an app survey in your web app in 10 to 15 minutes. Lets go!
<Note>
App Surveys are ideal for websites that **have a user authentication** system. If you are looking to run
surveys on your public facing website, head over to the [Website Surveys Quickstart
Guide](/website-surveys/quickstart).
App Surveys are ideal for websites that **have a user authentication** system. If you are looking to run surveys on your public facing website, head over to the [Website Surveys Quickstart Guide](/website-surveys/quickstart).
</Note>
1. **Create a free Formbricks Cloud account**: While you can [self-host](/self-hosting/deployment) Formbricks, but the quickest and easiest way to get started is with the free Cloud plan. Just [sign up here](https://app.formbricks.com/auth/signup) and you'll be guided to our onboarding like below:

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 56 KiB

View File

@@ -16,7 +16,7 @@ export const metadata = {
### Overview
The Formbricks JS SDK is a 2-in-1 SDK for seamlessly integrating both App Surveys and Website Surveys into your projects. In this section, we'll explore how to leverage the SDK specifically for **app** surveys. Its available on npm [here](https://www.npmjs.com/package/@formbricks/js/).
The Formbricks JS SDK is a 2-in-1 SDK for seamlessly integrating both App Surveys and Website Surveys into your projects. In this section, we'll explore how to leverage the SDK specifically for **app** surveys. Its available on npm [here](https://www.npmjs.com/package/@formbricks/js/).
### Install
@@ -26,9 +26,11 @@ The Formbricks JS SDK is a 2-in-1 SDK for seamlessly integrating both App Survey
```js {{ title: 'npm' }}
npm install @formbricks/js
```
```js {{ title: 'yarn' }}
yarn add @formbricks/js
```
```js {{ title: 'pnpm' }}
pnpm add @formbricks/js
```
@@ -51,9 +53,10 @@ import formbricks from "@formbricks/js/app";
formbricks.init({
environmentId: "<your-environment-id>", // required
apiHost: "<your-api-host>", // required
userId: "<user-id>" // required
userId: "<user-id>", // required
});
```
</CodeGroup>
</Col>
@@ -67,15 +70,16 @@ Formbricks JS is a client SDK meant to be run client-side in their browser so ma
```js
if (window !== undefined) {
formbricks.init({
environmentId: "<your-environment-id>",
apiHost: "<your-api-host>",
userId: "<user-id>"
});
formbricks.init({
environmentId: "<your-environment-id>",
apiHost: "<your-api-host>",
userId: "<user-id>",
});
} else {
console.error("Window object not accessible to init Formbricks");
console.error("Window object not accessible to init Formbricks");
}
```
</CodeGroup>
</Col>
@@ -91,6 +95,7 @@ You can set custom attributes for the identified user. This can be helpful for s
```js
formbricks.setAttribute("Plan", "Paid");
```
</CodeGroup>
</Col>
@@ -104,10 +109,10 @@ Track user actions to trigger surveys based on user interactions, such as button
```js
formbricks.track("Clicked on Claim");
```
</CodeGroup>
</Col>
### Logout
To log out and deinitialize Formbricks, use the formbricks.logout() function. This action clears the current initialization configuration and erases stored frontend information, such as the surveys a user has viewed or completed. It's an important step when a user logs out of your application or when you want to reset Formbricks.
@@ -118,6 +123,7 @@ To log out and deinitialize Formbricks, use the formbricks.logout() function. Th
```js
formbricks.logout();
```
</CodeGroup>
</Col>
@@ -133,6 +139,7 @@ Reset the current instance and fetch the latest surveys and state again:
```js
formbricks.reset();
```
</CodeGroup>
</Col>
@@ -140,7 +147,11 @@ formbricks.reset();
Listen for page changes and dynamically show surveys configured via no-code actions in the Formbricks app:
<Note> This is only needed when your framework has a custom routing system and you want to trigger surveys on route changes. For example: NextJs</Note>
<Note>
{" "}
This is only needed when your framework has a custom routing system and you want to trigger surveys on route
changes. For example: NextJs
</Note>
<Col>
<CodeGroup>
@@ -148,6 +159,7 @@ Listen for page changes and dynamically show surveys configured via no-code acti
```js
formbricks.registerRouteChange();
```
</CodeGroup>
</Col>
@@ -167,7 +179,7 @@ In case you dont see your survey right away, here's what you can do. Go throu
### Formbricks Cloud and your app are not connected properly.
Go back to [app.formbricks.com](http://app.formbricks.com) or your self-hosted instance's URL and go to the Setup Checklist in the Settings. If the status is still indicated as “Not connected” your app hasn't yet pinged the Formbricks Cloud:
Go back to [app.formbricks.com](http://app.formbricks.com) or your self-hosted instance's URL and go to the App connection in the Configuration. If the status is still indicated as “Not connected” your app hasn't yet pinged the Formbricks Cloud:
<MdxImage
src={I1}

View File

@@ -67,8 +67,7 @@ The API requests are authorized with a personal API key. This API key gives you
/>
<Note>
### Store API key safely!
Anyone who has your API key has full control over your account. For security reasons, you cannot view the API key again.
### Store API key safely! Anyone who has your API key has full control over your account. For security reasons, you cannot view the API key again.
</Note>
### Test your API Key
@@ -123,7 +122,8 @@ Hit the below request to verify that you are authenticated with your API Key and
"id": "cll2m30r60003mx0hnemjfckr",
"name": "My Product"
},
"widgetSetupCompleted": false
"appSetupCompleted": false,
"websiteSetupCompleted": false,
}
```
```json {{ title: '401 Not Authenticated' }}

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

View File

@@ -1,4 +1,5 @@
import { MdxImage } from "@/components/MdxImage";
import I1 from "./images/1-set-up-website-micro-survey-popup.webp";
export const metadata = {
title: "Formbricks Website Survey SDK",
@@ -142,4 +143,25 @@ This activates detailed debug messages in the browser console, providing deeper
---
## Troubleshooting
In case you dont see your survey right away, here's what you can do. Go through these to find the error fast:
### Formbricks Cloud and your website are not connected properly.
Go back to [app.formbricks.com](http://app.formbricks.com) or your self-hosted instance's URL and go to the Website connection in the Configuration. If the status is still indicated as “Not connected” your app hasn't yet pinged the Formbricks Cloud:
<MdxImage
src={I1}
alt="setup checklist ui of survey popup for website surveys"
quality="100"
className="max-w-full rounded-lg sm:max-w-3xl"
/>
**How to fix it:**
1. Check if your website loads the Formbricks widget correctly.
2. Make sure you have `debug` mode enabled in your integration and you should see the Formbricks debug logs in your browser console while being in your app (right click in the browser, `Inspect`, switch to the console tab). If you dont see them, double check your integration.
---
If you have any questions or need help, feel free to reach out to us on our **[Discord](https://formbricks.com/discord)**

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View File

@@ -67,8 +67,7 @@ The API requests are authorized with a personal API key. This API key gives you
/>
<Note>
### Store API key safely! Anyone who has your API key has full control over your account. For security
reasons, you cannot view the API key again.
### Store API key safely! Anyone who has your API key has full control over your account. For security reasons, you cannot view the API key again.
</Note>
### Test your API Key
@@ -123,7 +122,8 @@ Hit the below request to verify that you are authenticated with your API Key and
"id": "cll2m30r60003mx0hnemjfckr",
"name": "My Product"
},
"widgetSetupCompleted": false
"appSetupCompleted": false,
"websiteSetupCompleted": false,
}
```
```json {{ title: '401 Not Authenticated' }}

View File

@@ -49,7 +49,7 @@ How to deliver a specific language depends on the survey type (app or link surve
className="max-w-full rounded-lg sm:max-w-3xl"
/>
2. Click on the **Edit Languages** button, to add a new language to your survey
2. Click on the **Edit languages** button, to add a new language to your survey
<MdxImage
src={SurveyLanguageSettings}
@@ -58,7 +58,7 @@ How to deliver a specific language depends on the survey type (app or link surve
className="max-w-full rounded-lg sm:max-w-3xl"
/>
3. Select the preferred language from the dropdown and assign an identifier Alias. Click the **Add Language** button to add the language to your product.
3. Select the preferred language from the dropdown and assign an identifier Alias. Click the **Add language** button to add the language to your product.
<MdxImage
src={AddLanguages}

View File

@@ -52,7 +52,6 @@ These variables are present inside your machines docker-compose file. Restart
| DEFAULT_BRAND_COLOR | Default brand color for your app (Can be overwritten from the UI as well). | optional | #64748b |
| DEFAULT_ORGANIZATION_ID | Automatically assign new users to a specific organization when joining | optional | |
| DEFAULT_ORGANIZATION_ROLE | Role of the user in the default organization. | optional | admin |
| ONBOARDING_DISABLED | Disables onboarding for new users if set to 1 | optional | |
| OIDC_DISPLAY_NAME | Display name for Custom OpenID Connect Provider | optional | |
| OIDC_CLIENT_ID | Client ID for Custom OpenID Connect Provider | optional (required if OIDC auth is enabled) | |
| OIDC_CLIENT_SECRET | Secret for Custom OpenID Connect Provider | optional (required if OIDC auth is enabled) | |

View File

@@ -8,6 +8,101 @@ export const metadata = {
# Migration Guide
## v2.2
Formbricks v2.2 introduces XM research presets into your products with a brand new product onboarding. Our objective is to make user research “obviously easy”, industry by industry. And we're starting with Software-as-a-Service and E-Commerce.
### Steps to Migrate
This guide is for users who are self-hosting Formbricks using our one-click setup. If you are using a different setup, you might adjust the commands accordingly.
To run all these steps, please navigate to the `formbricks` folder where your `docker-compose.yml` file is located.
1. **Backup your Database**: This is a crucial step. Please make sure to backup your database before proceeding with the upgrade. You can use the following command to backup your database:
<Col>
<CodeGroup title="Backup Postgres">
```bash
docker exec formbricks-postgres-1 pg_dump -Fc -U postgres -d formbricks > formbricks_pre_v2.2_$(date +%Y%m%d_%H%M%S).dump
```
</CodeGroup>
</Col>
<Note>
If you run into “No such container”, use `docker ps` to find your container name, e.g.
`formbricks_postgres_1`.
</Note>
<Note>
If you prefer storing the backup as an `*.sql` file remove the `-Fc` (custom format) option. In case of a
restore scenario you will need to use `psql` then with an empty `formbricks` database.
</Note>
2. Pull the latest version of Formbricks:
<Col>
<CodeGroup title="Stop the containers">
```bash
docker compose pull
```
</CodeGroup>
</Col>
3. Stop the running Formbricks instance & remove the related containers:
<Col>
<CodeGroup title="Stop the containers">
```bash
docker compose down
```
</CodeGroup>
</Col>
4. Restarting the containers with the latest version of Formbricks:
<Col>
<CodeGroup title="Restart the containers">
```bash
docker compose up -d
```
</CodeGroup>
</Col>
5. Now let's migrate the data to the latest schema:
<Note>To find your Docker Network name for your Postgres Database, find it using `docker network ls`</Note>
<Col>
<CodeGroup title="Migrate the data">
```bash
docker pull ghcr.io/formbricks/data-migrations:latest && \
docker run --rm \
--network=formbricks_default \
-e DATABASE_URL="postgresql://postgres:postgres@postgres:5432/formbricks?schema=public" \
-e UPGRADE_TO_VERSION="v2.2" \
ghcr.io/formbricks/data-migrations:latest
```
</CodeGroup>
</Col>
The above command will migrate your data to the latest schema. This is a crucial step to migrate your existing data to the new structure. Only if the script runs successful, changes are made to the database. The script can safely run multiple times.
6. That's it! Once the migration is complete, you can **now access your Formbricks instance** at the same URL as before.
### Changes in Environment Variables
- `ONBOARDING_DISABLED` is now deprecated since we replaced the user onboarding with a product onboarding that only runs when creating a new product.
## v2.1
Formbricks v2.1 introduces more options for creating No-Code Actions and lays the foundation for easier self-hosting of Formbricks starting with an Onboarding for fresh instances.

View File

@@ -22,9 +22,7 @@ export const metadata = {
Website Surveys make it easy for your public website visitors to give you feedback. They are a great way to get feedback from your users, without interrupting their workflow. This quickstart guide will show you how to create your first website survey in under 5 minutes.
<Note>
Website Surveys are ideal for **public facing websites**. If you are looking to run surveys in your app
where you have user identification & want advanced user targeting, head over to the [App Surveys Quickstart
Guide](/app-surveys/quickstart).
Website Surveys are ideal for **public facing websites**. If you are looking to run surveys in your app where you have user identification & want advanced user targeting, head over to the [App Surveys Quickstart Guide](/app-surveys/quickstart).
</Note>
1. **Create a free Formbricks Cloud account**: While you can [self-host](/self-hosting/deployment) Formbricks, but the quickest and easiest way to get started is with the free Cloud plan. Just [sign up here](https://app.formbricks.com/auth/signup) and you'll be guided to our onboarding like below:

View File

@@ -115,7 +115,7 @@ const SmallPrint = () => {
export const Footer = () => {
return (
<footer className="mx-auto w-full max-w-2xl space-y-10 pb-16 lg:max-w-5xl">
<footer className="my-10 flex-auto pb-16">
<PageNavigation />
<SmallPrint />
</footer>

View File

@@ -44,7 +44,7 @@ const Anchor = ({ id, inView, children }: { id: string; inView: boolean; childre
);
};
export const Heading = <Level extends 2 | 3>({
export const Heading = <Level extends 2 | 3 | 4>({
children,
tag,
label,
@@ -59,11 +59,11 @@ export const Heading = <Level extends 2 | 3>({
anchor?: boolean;
}) => {
level = level ?? (2 as Level);
let Component = `h${level}` as "h2" | "h3";
let ref = useRef<HTMLHeadingElement>(null);
let registerHeading = useSectionStore((s) => s.registerHeading);
const Component: "h2" | "h3" | "h4" = `h${level}`;
const ref = useRef<HTMLHeadingElement>(null);
const registerHeading = useSectionStore((s) => s.registerHeading);
let inView = useInView(ref, {
const inView = useInView(ref, {
margin: `${remToPx(-3.5)}px 0px 0px 0px`,
amount: "all",
});
@@ -71,8 +71,12 @@ export const Heading = <Level extends 2 | 3>({
useEffect(() => {
if (level === 2) {
registerHeading({ id: props.id, ref, offsetRem: tag || label ? 8 : 6 });
} else if (level === 3) {
registerHeading({ id: props.id, ref, offsetRem: tag || label ? 7 : 5 });
} else if (level === 4) {
registerHeading({ id: props.id, ref, offsetRem: tag || label ? 6 : 4 });
}
});
}, [label, level, props.id, registerHeading, tag]);
return (
<>

View File

@@ -2,6 +2,7 @@
import { Logo } from "@/components/Logo";
import { Navigation } from "@/components/Navigation";
import { SideNavigation } from "@/components/SideNavigation";
import { motion } from "framer-motion";
import Link from "next/link";
import { usePathname } from "next/navigation";
@@ -16,7 +17,7 @@ export const Layout = ({
children: React.ReactNode;
allSections: Record<string, Array<Section>>;
}) => {
let pathname = usePathname();
const pathname = usePathname();
return (
<SectionProvider sections={allSections[pathname || ""] ?? []}>
@@ -34,9 +35,12 @@ export const Layout = ({
<Navigation className="hidden lg:mt-10 lg:block" isMobile={false} />
</div>
</motion.header>
<div className="relative flex h-full flex-col px-4 pt-14 sm:px-6 lg:px-8">
<main className="flex-auto">{children}</main>
<Footer />
<div className="flex h-screen flex-col">
<div className="flex flex-col px-4 pt-14 sm:px-6 lg:w-[calc(100%-20rem)] lg:px-8">
<main className="overflow-y-auto overflow-x-hidden">{children}</main>
<Footer />
</div>
<SideNavigation pathname={pathname} />
</div>
</div>
</SectionProvider>

View File

@@ -1,6 +1,15 @@
import Image, { ImageProps } from "next/image";
import React from "react";
export const MdxImage = (props: ImageProps) => {
return <Image {...props} alt={props.alt} />;
return (
<Image
{...props}
alt={props.alt}
sizes="100vw"
style={{
width: "100%",
height: "auto",
}}
/>
);
};

View File

@@ -0,0 +1,82 @@
import { useTableContentObserver } from "@/hooks/useTableContentObserver";
import Link from "next/link";
import { useEffect, useState } from "react";
type Heading = {
id: string;
text: string | null;
level: number;
};
export const SideNavigation = ({ pathname }) => {
const [headings, setHeadings] = useState<Heading[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
useTableContentObserver(setSelectedId, pathname);
useEffect(() => {
const getHeadings = () => {
// Select all heading elements (h2, h3, h4) with an 'id' attribute
const headingElements = document.querySelectorAll("h2[id], h3[id], h4[id]");
// Convert the NodeList of heading elements into an array and map them to an array of 'Heading' objects
const headings: Heading[] = Array.from(headingElements).map((heading) => ({
id: heading.id,
text: heading.textContent,
level: parseInt(heading.tagName.slice(1)),
}));
// Check if there are any h2 headings in the list
const hasH2 = headings.some((heading) => heading.level === 2);
// Update the 'headings' state with the retrieved headings, but only if there are h2 headings
setHeadings(hasH2 ? headings : []);
};
getHeadings();
}, [pathname]);
const renderHeading = (items: Heading[], currentLevel: number) => (
<ul className="ml-1 mt-4">
{items.map((heading, index) => {
if (heading.level === currentLevel) {
let nextIndex = index + 1;
while (nextIndex < items.length && items[nextIndex].level > currentLevel) {
nextIndex++;
}
return (
<li
key={heading.text}
className={`mb-4 ml-4 text-slate-900 dark:text-white ml-${heading.level === 2 ? 0 : heading.level === 3 ? 4 : 6}`}>
<Link
href={`#${heading.id}`}
onClick={() => setSelectedId(heading.id)}
className={`${
heading.id === selectedId
? "text-brand font-medium text-opacity-35"
: "font-normal text-slate-600 hover:text-slate-950 dark:text-white dark:hover:text-slate-50"
}`}>
{heading.text}
</Link>
{nextIndex > index + 1 && renderHeading(items.slice(index + 1, nextIndex), currentLevel + 1)}
</li>
);
}
return null;
})}
</ul>
);
if (headings.length) {
return (
<aside className="fixed right-0 top-0 hidden h-[calc(100%-2.5rem)] w-80 overflow-hidden overflow-y-auto pr-8 pt-16 text-sm [scrollbar-width:none] lg:mt-10 lg:block">
<div className="border-l border-slate-200 dark:border-slate-700">
<h3 className="ml-5 mt-1 text-xs font-semibold uppercase text-slate-400">on this page</h3>
{renderHeading(headings, 2)}
</div>
</aside>
);
}
return null;
};

View File

@@ -19,10 +19,19 @@ export const wrapper = ({ children }: { children: React.ReactNode }) => {
);
};
export const h2 = (props: Omit<React.ComponentPropsWithoutRef<typeof Heading>, "level">) => {
return <Heading level={2} {...props} />;
const createHeadingComponent = (level: 2 | 3 | 4) => {
const Component = (props: Omit<React.ComponentPropsWithoutRef<typeof Heading>, "level">) => {
return <Heading level={level} {...props} />;
};
Component.displayName = `H${level}`;
return Component;
};
export const h2 = createHeadingComponent(2);
export const h3 = createHeadingComponent(3);
export const h4 = createHeadingComponent(4);
const InfoIcon = (props: React.ComponentPropsWithoutRef<"svg">) => {
return (
<svg viewBox="0 0 16 16" aria-hidden="true" {...props}>

View File

@@ -0,0 +1,71 @@
import { useEffect, useRef } from "react";
interface HeadingElement extends IntersectionObserverEntry {
target: HTMLHeadingElement;
}
/**
* A custom hook that sets up an IntersectionObserver to track the visibility of headings on the page.
*
* @param {Function} setActiveId - A function to set the active heading ID.
* @param {string} pathname - The current pathname, used as a dependency for the useEffect hook.
* @returns {void}
*
* This hook performs the following tasks:
* 1. Creates a map of heading elements, where the key is the heading's ID and the value is the heading element.
* 2. Finds the visible headings (i.e., headings that are currently intersecting with the viewport).
* 3. If there is only one visible heading, sets it as the active heading using the `setActiveId` function.
* 4. If there are multiple visible headings, sets the active heading to the one that is highest on the page (i.e., the one with the lowest index in the `headingElements` array).
* 5. Cleans up the IntersectionObserver and the `headingElementsRef` when the component is unmounted.
*/
export const useTableContentObserver = (setActiveId: (id: string) => void, pathname: string) => {
const headingElementsRef = useRef<Record<string, HeadingElement>>({});
useEffect(() => {
const callback = (headings: HeadingElement[]) => {
// Create a map of heading elements, where the key is the heading's ID and the value is the heading element
headingElementsRef.current = headings.reduce(
(map, headingElement) => {
return { ...map, [headingElement.target.id]: headingElement };
},
{} as Record<string, HeadingElement>
);
// Find the visible headings (i.e., headings that are currently intersecting with the viewport)
const visibleHeadings: HeadingElement[] = [];
Object.keys(headingElementsRef.current).forEach((key) => {
const headingElement = headingElementsRef.current[key];
if (headingElement.isIntersecting) visibleHeadings.push(headingElement);
});
// Define a function to get the index of a heading element in the headingElements array
const getIndexFromId = (id: string) => headingElements.findIndex((heading) => heading.id === id);
// If there is only one visible heading, set it as the active heading
if (visibleHeadings.length === 1) {
setActiveId(visibleHeadings[0].target.id);
}
// If there are multiple visible headings, set the active heading to the one that is highest on the page
else if (visibleHeadings.length > 1) {
const sortedVisibleHeadings = visibleHeadings.sort((a, b) => {
const aIndex = getIndexFromId(a.target.id);
const bIndex = getIndexFromId(b.target.id);
return aIndex - bIndex;
});
setActiveId(sortedVisibleHeadings[0].target.id);
}
};
const observer = new IntersectionObserver(callback, {
rootMargin: "-40px 0px -40% 0px",
});
const headingElements = Array.from(document.querySelectorAll("h2[id], h3[id], h4[id]"));
headingElements.forEach((element) => observer.observe(element));
return () => {
observer.disconnect();
headingElementsRef.current = {};
};
}, [setActiveId, pathname]);
};

View File

@@ -46,9 +46,9 @@ const rehypeShiki = () => {
const rehypeSlugify = () => {
return (tree) => {
let slugify = slugifyWithCounter();
const slugify = slugifyWithCounter();
visit(tree, "element", (node) => {
if (node.tagName === "h2" && !node.properties.id) {
if (["h2", "h3", "h4"].includes(node.tagName) && !node.properties.id) {
node.properties.id = slugify(toString(node));
}
});
@@ -83,15 +83,15 @@ const rehypeAddMDXExports = (getExports) => {
};
const getSections = (node) => {
let sections = [];
const sections = [];
for (let child of node.children ?? []) {
if (child.type === "element" && child.tagName === "h2") {
for (const child of node.children ?? []) {
if (child.type === "element" && ["h2", "h3", "h4"].includes(child.tagName)) {
sections.push(`{
title: ${JSON.stringify(toString(child))},
id: ${JSON.stringify(child.properties.id)},
...${child.properties.annotation}
}`);
title: ${JSON.stringify(toString(child))},
id: ${JSON.stringify(child.properties.id)},
...${child.properties.annotation}
}`);
} else if (child.children) {
sections.push(...getSections(child));
}

View File

@@ -12,34 +12,34 @@
},
"browserslist": "defaults, not ie <= 11",
"dependencies": {
"@algolia/autocomplete-core": "^1.17.0",
"@calcom/embed-react": "^1.4.0",
"@algolia/autocomplete-core": "^1.17.2",
"@calcom/embed-react": "^1.5.0",
"@docsearch/css": "3",
"@docsearch/react": "^3.6.0",
"@formbricks/lib": "workspace:*",
"@formbricks/types": "workspace:*",
"@formbricks/ui": "workspace:*",
"@headlessui/react": "^2.0.3",
"@headlessui/tailwindcss": "^0.2.0",
"@headlessui/react": "^2.0.4",
"@headlessui/tailwindcss": "^0.2.1",
"@mapbox/rehype-prism": "^0.9.0",
"@mdx-js/loader": "^3.0.1",
"@mdx-js/react": "^3.0.1",
"@next/mdx": "14.2.3",
"@next/mdx": "14.2.4",
"@paralleldrive/cuid2": "^2.2.2",
"@sindresorhus/slugify": "^2.2.1",
"@tailwindcss/typography": "^0.5.13",
"acorn": "^8.11.3",
"acorn": "^8.12.0",
"autoprefixer": "^10.4.19",
"clsx": "^2.1.1",
"fast-glob": "^3.3.2",
"flexsearch": "^0.7.43",
"framer-motion": "11.1.9",
"framer-motion": "11.2.11",
"lottie-web": "^5.12.2",
"lucide": "^0.378.0",
"lucide-react": "^0.378.0",
"lucide": "^0.395.0",
"lucide-react": "^0.395.0",
"mdast-util-to-string": "^4.0.0",
"mdx-annotations": "^0.1.4",
"next": "14.2.3",
"next": "14.2.4",
"next-plausible": "^3.12.0",
"next-seo": "^6.5.0",
"next-sitemap": "^4.2.3",
@@ -59,7 +59,7 @@
"sharp": "^0.33.4",
"shiki": "^0.14.7",
"simple-functional-loader": "^1.2.1",
"tailwindcss": "^3.4.3",
"tailwindcss": "^3.4.4",
"unist-util-filter": "^5.0.1",
"unist-util-visit": "^5.0.0",
"zustand": "^4.5.2"
@@ -67,7 +67,7 @@
"devDependencies": {
"@formbricks/config-typescript": "workspace:*",
"@types/dompurify": "^3.0.5",
"@types/react-highlight-words": "^0.16.7",
"@types/react-highlight-words": "^0.20.0",
"@formbricks/eslint-config": "workspace:*"
}
}

View File

@@ -1,6 +0,0 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference types="next/navigation-types/compat/navigation" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.

View File

@@ -1,118 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:news="http://www.google.com/schemas/sitemap-news/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:mobile="http://www.google.com/schemas/sitemap-mobile/1.0" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1" xmlns:video="http://www.google.com/schemas/sitemap-video/1.1">
<url><loc>https://formbricks.com</loc><lastmod>2024-04-08T07:33:12.606Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/blog</loc><lastmod>2024-04-08T07:33:12.613Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/blog/author/johannes</loc><lastmod>2024-04-08T07:33:12.613Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/blog/author/shubham</loc><lastmod>2024-04-08T07:33:12.613Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/blog/author/sudhanshu</loc><lastmod>2024-04-08T07:33:12.613Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/blog/best-feedback-app-and-how-to-use-them</loc><lastmod>2024-04-08T07:33:12.613Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/blog/best-hotjar-alternatives-2024-incl-open-source</loc><lastmod>2024-04-08T07:33:12.613Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/blog/best-open-source-survey-software-2023</loc><lastmod>2024-04-08T07:33:12.613Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/blog/best-website-feedback-tools-2024</loc><lastmod>2024-04-08T07:33:12.613Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/blog/experience-management-open-source</loc><lastmod>2024-04-08T07:33:12.614Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/blog/first-end-to-endcommunity-feature</loc><lastmod>2024-04-08T07:33:12.614Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/blog/github-accelerator-experience</loc><lastmod>2024-04-08T07:33:12.614Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/blog/granular-targeting-in-app-surveys</loc><lastmod>2024-04-08T07:33:12.614Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/blog/how-smart-writers-use-formbricks-open-source-tool-to-measure-the-quality-of-their-newsletter-content</loc><lastmod>2024-04-08T07:33:12.614Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/blog/inaugural-batch-github-accelerator</loc><lastmod>2024-04-08T07:33:12.614Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/blog/join-the-formtribe</loc><lastmod>2024-04-08T07:33:12.614Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/blog/open-source-forms-will-save-the-world</loc><lastmod>2024-04-08T07:33:12.614Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/blog/open-source-qualtrics-beats-typeform</loc><lastmod>2024-04-08T07:33:12.614Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/blog/remove-branding-for-free</loc><lastmod>2024-04-08T07:33:12.614Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/blog/snoopforms-becomes-formbricks</loc><lastmod>2024-04-08T07:33:12.614Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/blog/terraform-ecs-aws-self-hosting</loc><lastmod>2024-04-08T07:33:12.614Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/blog/the-perfect-waitlist-survey</loc><lastmod>2024-04-08T07:33:12.614Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/blog/understanding-formbricks-self-hosting</loc><lastmod>2024-04-08T07:33:12.614Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/blog/why-open-source-no-code-is-the-future-of-enterprise-gov-software</loc><lastmod>2024-04-08T07:33:12.614Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/careers</loc><lastmod>2024-04-08T07:33:12.614Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/community</loc><lastmod>2024-04-08T07:33:12.614Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/community/ContributorGrid</loc><lastmod>2024-04-08T07:33:12.614Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/community/HallOfFame</loc><lastmod>2024-04-08T07:33:12.614Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/community/HeaderTribe</loc><lastmod>2024-04-08T07:33:12.614Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/community/LayoutTribe</loc><lastmod>2024-04-08T07:33:12.614Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/community/LevelCard</loc><lastmod>2024-04-08T07:33:12.614Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/community/LevelGrid</loc><lastmod>2024-04-08T07:33:12.614Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/community/Roadmap</loc><lastmod>2024-04-08T07:33:12.614Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/concierge</loc><lastmod>2024-04-08T07:33:12.614Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/docs-feedback</loc><lastmod>2024-04-08T07:33:12.614Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/feature-chaser</loc><lastmod>2024-04-08T07:33:12.614Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/feedback-box</loc><lastmod>2024-04-08T07:33:12.614Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/gdpr</loc><lastmod>2024-04-08T07:33:12.614Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/gdpr-guide</loc><lastmod>2024-04-08T07:33:12.614Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/imprint</loc><lastmod>2024-04-08T07:33:12.614Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/improve-trial-conversion</loc><lastmod>2024-04-08T07:33:12.614Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/in-app-survey</loc><lastmod>2024-04-08T07:33:12.614Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/interview-prompt</loc><lastmod>2024-04-08T07:33:12.614Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/learn-from-churn</loc><lastmod>2024-04-08T07:33:12.614Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/measure-product-market-fit</loc><lastmod>2024-04-08T07:33:12.614Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/onboarding-segmentation</loc><lastmod>2024-04-08T07:33:12.614Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/open-source-form-builder</loc><lastmod>2024-04-08T07:33:12.614Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/oss-friends</loc><lastmod>2024-04-08T07:33:12.614Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/pricing</loc><lastmod>2024-04-08T07:33:12.614Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/privacy-policy</loc><lastmod>2024-04-08T07:33:12.614Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/terms</loc><lastmod>2024-04-08T07:33:12.614Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/vs-formspree</loc><lastmod>2024-04-08T07:33:12.614Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/vs-google-forms</loc><lastmod>2024-04-08T07:33:12.614Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/vs-ohmyform</loc><lastmod>2024-04-08T07:33:12.614Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/website-survey</loc><lastmod>2024-04-08T07:33:12.614Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/docs/api/client/responses</loc><lastmod>2024-04-08T07:33:12.615Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/docs/api/management/action-classes</loc><lastmod>2024-04-08T07:33:12.615Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/docs/api/management/attribute-classes</loc><lastmod>2024-04-08T07:33:12.615Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/docs/api/management/me</loc><lastmod>2024-04-08T07:33:12.615Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/docs/api/management/people</loc><lastmod>2024-04-08T07:33:12.615Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/docs/api/client/overview</loc><lastmod>2024-04-08T07:33:12.615Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/docs/api/management/responses</loc><lastmod>2024-04-08T07:33:12.615Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/docs/api/management/webhooks</loc><lastmod>2024-04-08T07:33:12.615Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/docs/api/client/displays</loc><lastmod>2024-04-08T07:33:12.615Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/docs/best-practices/cancel-subscription</loc><lastmod>2024-04-08T07:33:12.615Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/docs/api/client/people</loc><lastmod>2024-04-08T07:33:12.615Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/docs/best-practices/feedback-box</loc><lastmod>2024-04-08T07:33:12.615Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/docs/best-practices/feature-chaser</loc><lastmod>2024-04-08T07:33:12.615Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/docs/best-practices/docs-feedback</loc><lastmod>2024-04-08T07:33:12.615Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/docs/best-practices/improve-trial-cr</loc><lastmod>2024-04-08T07:33:12.615Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/docs/api/management/api-key-setup</loc><lastmod>2024-04-08T07:33:12.615Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/docs/best-practices/improve-email-content</loc><lastmod>2024-04-08T07:33:12.615Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/docs/best-practices/pmf-survey</loc><lastmod>2024-04-08T07:33:12.615Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/docs/best-practices/interview-prompt</loc><lastmod>2024-04-08T07:33:12.615Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/docs/contributing/demo</loc><lastmod>2024-04-08T07:33:12.615Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/docs/contributing/how-we-code</loc><lastmod>2024-04-08T07:33:12.615Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/docs/contributing/introduction</loc><lastmod>2024-04-08T07:33:12.615Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/docs/contributing/creating-a-service</loc><lastmod>2024-04-08T07:33:12.615Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/docs/contributing/setup</loc><lastmod>2024-04-08T07:33:12.615Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/docs/additional-features/multi-language-surveys</loc><lastmod>2024-04-08T07:33:12.615Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/docs/contributing/troubleshooting</loc><lastmod>2024-04-08T07:33:12.615Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/docs/getting-started/quickstart-in-app-survey</loc><lastmod>2024-04-08T07:33:12.615Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/docs/faq</loc><lastmod>2024-04-08T07:33:12.615Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/docs/in-app-surveys/actions</loc><lastmod>2024-04-08T07:33:12.615Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/docs/api/client/actions</loc><lastmod>2024-04-08T07:33:12.615Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/docs/getting-started/framework-guides</loc><lastmod>2024-04-08T07:33:12.615Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/docs/in-app-surveys/advanced-targeting</loc><lastmod>2024-04-08T07:33:12.615Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/docs/in-app-surveys/user-identification</loc><lastmod>2024-04-08T07:33:12.615Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/docs/getting-started/troubleshooting</loc><lastmod>2024-04-08T07:33:12.615Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/docs/in-app-surveys/attributes</loc><lastmod>2024-04-08T07:33:12.615Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/docs/api/management/surveys</loc><lastmod>2024-04-08T07:33:12.615Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/docs/integrations/make</loc><lastmod>2024-04-08T07:33:12.615Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/docs/integrations/airtable</loc><lastmod>2024-04-08T07:33:12.615Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/docs/integrations/notion</loc><lastmod>2024-04-08T07:33:12.615Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/docs/integrations/zapier</loc><lastmod>2024-04-08T07:33:12.615Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/docs/link-surveys/data-prefilling</loc><lastmod>2024-04-08T07:33:12.615Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/docs/integrations/wordpress</loc><lastmod>2024-04-08T07:33:12.615Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/docs/introduction/what-is-formbricks</loc><lastmod>2024-04-08T07:33:12.615Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/docs/introduction/how-it-works</loc><lastmod>2024-04-08T07:33:12.615Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/docs/introduction/why-is-it-better</loc><lastmod>2024-04-08T07:33:12.615Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/docs/link-surveys/single-use-links</loc><lastmod>2024-04-08T07:33:12.615Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/docs/link-surveys/hidden-fields</loc><lastmod>2024-04-08T07:33:12.615Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/docs/link-surveys/quickstart</loc><lastmod>2024-04-08T07:33:12.615Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/docs/link-surveys/source-tracking</loc><lastmod>2024-04-08T07:33:12.615Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/docs/self-hosting/docker</loc><lastmod>2024-04-08T07:33:12.615Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/docs/self-hosting/deployment</loc><lastmod>2024-04-08T07:33:12.615Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/docs/link-surveys/start-at-question</loc><lastmod>2024-04-08T07:33:12.615Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/docs/link-surveys/user-identification</loc><lastmod>2024-04-08T07:33:12.615Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/docs/self-hosting/enterprise</loc><lastmod>2024-04-08T07:33:12.615Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/docs/self-hosting/external-auth-providers</loc><lastmod>2024-04-08T07:33:12.615Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/docs/self-hosting/migration-guide</loc><lastmod>2024-04-08T07:33:12.615Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/docs/self-hosting/production</loc><lastmod>2024-04-08T07:33:12.615Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/docs/self-hosting/license</loc><lastmod>2024-04-08T07:33:12.615Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/docs/integrations/n8n</loc><lastmod>2024-04-08T07:33:12.615Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://formbricks.com/docs/integrations/google-sheets</loc><lastmod>2024-04-08T07:33:12.615Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
</urlset>

View File

@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<sitemap><loc>https://formbricks.com/sitemap-0.xml</loc></sitemap>
</sitemapindex>

View File

@@ -19,22 +19,22 @@
"devDependencies": {
"@chromatic-com/storybook": "^1.5.0",
"@formbricks/config-typescript": "workspace:*",
"@storybook/addon-essentials": "^8.1.5",
"@storybook/addon-interactions": "^8.1.5",
"@storybook/addon-links": "^8.1.5",
"@storybook/addon-onboarding": "^8.1.5",
"@storybook/blocks": "^8.1.5",
"@storybook/react": "^8.1.5",
"@storybook/react-vite": "^8.1.5",
"@storybook/test": "^8.1.5",
"@typescript-eslint/eslint-plugin": "^7.12.0",
"@typescript-eslint/parser": "^7.12.0",
"@vitejs/plugin-react": "^4.3.0",
"esbuild": "^0.21.4",
"@storybook/addon-essentials": "^8.1.10",
"@storybook/addon-interactions": "^8.1.10",
"@storybook/addon-links": "^8.1.10",
"@storybook/addon-onboarding": "^8.1.10",
"@storybook/blocks": "^8.1.10",
"@storybook/react": "^8.1.10",
"@storybook/react-vite": "^8.1.10",
"@storybook/test": "^8.1.10",
"@typescript-eslint/eslint-plugin": "^7.13.1",
"@typescript-eslint/parser": "^7.13.1",
"@vitejs/plugin-react": "^4.3.1",
"esbuild": "^0.21.5",
"eslint-plugin-storybook": "^0.8.0",
"prop-types": "^15.8.1",
"storybook": "^8.1.5",
"storybook": "^8.1.10",
"tsup": "^8.1.0",
"vite": "^5.2.12"
"vite": "^5.3.1"
}
}

View File

@@ -0,0 +1,90 @@
"use client";
import Dance from "@/images/onboarding-dance.gif";
import Lost from "@/images/onboarding-lost.gif";
import { ArrowRight } from "lucide-react";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { cn } from "@formbricks/lib/cn";
import { TEnvironment } from "@formbricks/types/environment";
import { TProductConfigChannel } from "@formbricks/types/product";
import { Button } from "@formbricks/ui/Button";
import { OnboardingSetupInstructions } from "./OnboardingSetupInstructions";
interface ConnectWithFormbricksProps {
environment: TEnvironment;
webAppUrl: string;
widgetSetupCompleted: boolean;
channel: TProductConfigChannel;
}
export const ConnectWithFormbricks = ({
environment,
webAppUrl,
widgetSetupCompleted,
channel,
}: ConnectWithFormbricksProps) => {
const router = useRouter();
const handleFinishOnboarding = async () => {
if (!widgetSetupCompleted) {
router.push(`/environments/${environment.id}/connect/invite`);
return;
}
router.push(`/environments/${environment.id}/surveys`);
};
useEffect(() => {
const handleVisibilityChange = async () => {
if (document.visibilityState === "visible") {
router.refresh();
}
};
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div className="mt-6 flex w-5/6 flex-col items-center space-y-10 lg:w-2/3 2xl:w-1/2">
<div className="flex w-full space-x-10">
<div className="flex w-1/2 flex-col space-y-4">
<OnboardingSetupInstructions
environmentId={environment.id}
webAppUrl={webAppUrl}
channel={channel}
widgetSetupCompleted={widgetSetupCompleted}
/>
</div>
<div
className={cn(
"flex h-[30rem] w-1/2 flex-col items-center justify-center rounded-lg border bg-slate-200 text-center shadow",
widgetSetupCompleted ? "border-green-500 bg-green-100" : ""
)}>
{widgetSetupCompleted ? (
<div>
<Image src={Dance} alt="lost" height={250} />
<p className="mt-6 text-xl font-bold">Connection successful </p>
</div>
) : (
<div className="space-y-4">
<Image src={Lost} alt="lost" height={250} />
<p className="pt-4 text-slate-400">Waiting for your signal...</p>
</div>
)}
</div>
</div>
<Button
id="finishOnboarding"
variant={widgetSetupCompleted ? "darkCTA" : "minimal"}
onClick={handleFinishOnboarding}
EndIcon={ArrowRight}>
{widgetSetupCompleted ? "Finish Onboarding" : "Skip"}
</Button>
</div>
);
};

View File

@@ -0,0 +1,121 @@
"use client";
import { inviteOrganizationMemberAction } from "@/app/(app)/(onboarding)/organizations/actions";
import { zodResolver } from "@hookform/resolvers/zod";
import { useRouter } from "next/navigation";
import { FormProvider, useForm } from "react-hook-form";
import { toast } from "react-hot-toast";
import { z } from "zod";
import { TOrganization } from "@formbricks/types/organizations";
import { Button } from "@formbricks/ui/Button";
import { FormControl, FormError, FormField, FormItem, FormLabel } from "@formbricks/ui/Form";
import { Input } from "@formbricks/ui/Input";
interface InviteOrganizationMemberProps {
organization: TOrganization;
environmentId: string;
}
const ZInviteOrganizationMemberDetails = z.object({
email: z.string().email(),
inviteMessage: z.string().trim().min(1),
});
type TInviteOrganizationMemberDetails = z.infer<typeof ZInviteOrganizationMemberDetails>;
export const InviteOrganizationMember = ({ organization, environmentId }: InviteOrganizationMemberProps) => {
const router = useRouter();
const form = useForm<TInviteOrganizationMemberDetails>({
defaultValues: {
email: "",
inviteMessage: "I'm looking into Formbricks to run targeted surveys. Can you help me set it up? 🙏",
},
resolver: zodResolver(ZInviteOrganizationMemberDetails),
});
const { isSubmitting } = form.formState;
const handleInvite = async (data: TInviteOrganizationMemberDetails) => {
try {
await inviteOrganizationMemberAction(organization.id, data.email, "developer", data.inviteMessage);
toast.success("Invite sent successful");
await finishOnboarding();
} catch (error) {
toast.error("An unexpected error occurred");
}
};
const finishOnboarding = async () => {
router.push(`/environments/${environmentId}/surveys`);
};
return (
<div className="mb-8 w-full max-w-xl space-y-8">
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(handleInvite)} className="w-full space-y-4">
<div className="space-y-4">
<FormField
control={form.control}
name="email"
render={({ field, fieldState: { error } }) => (
<FormItem className="w-full space-y-4">
<FormLabel>Email</FormLabel>
<FormControl>
<div>
<Input
value={field.value}
onChange={(email) => field.onChange(email)}
placeholder="engineering@acme.com"
className=" bg-white"
/>
{error?.message && <FormError className="text-left">{error.message}</FormError>}
</div>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="inviteMessage"
render={({ field, fieldState: { error } }) => (
<FormItem className="w-full space-y-4">
<FormLabel>Invite Message</FormLabel>
<FormControl>
<div>
<textarea
rows={5}
className="focus:border-brand-dark flex w-full rounded-md border border-slate-300 bg-transparent bg-white px-3 py-2 text-sm text-slate-800 placeholder:text-slate-400 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-500 dark:text-slate-300"
value={field.value}
onChange={(inviteMessage) => field.onChange(inviteMessage)}
/>
{error?.message && <FormError className="text-left">{error.message}</FormError>}
</div>
</FormControl>
</FormItem>
)}
/>
<div className="flex w-full justify-end space-x-2">
<Button
id="onboarding-inapp-invite-have-a-look-first"
className="font-normal text-slate-400"
variant="minimal"
onClick={(e) => {
e.preventDefault();
finishOnboarding();
}}>
Skip
</Button>
<Button
id="onboarding-inapp-invite-send-invite"
variant="darkCTA"
type={"submit"}
loading={isSubmitting}>
Invite
</Button>
</div>
</div>
</form>
</FormProvider>
</div>
);
};

View File

@@ -0,0 +1,161 @@
"use client";
import "prismjs/themes/prism.css";
import { useState } from "react";
import toast from "react-hot-toast";
import { TProductConfigChannel } from "@formbricks/types/product";
import { Button } from "@formbricks/ui/Button";
import { CodeBlock } from "@formbricks/ui/CodeBlock";
import { TabBar } from "@formbricks/ui/TabBar";
import { Html5Icon, NpmIcon } from "@formbricks/ui/icons";
const tabs = [
{ id: "html", label: "HTML", icon: <Html5Icon /> },
{ id: "npm", label: "NPM", icon: <NpmIcon /> },
];
interface OnboardingSetupInstructionsProps {
environmentId: string;
webAppUrl: string;
channel: TProductConfigChannel;
widgetSetupCompleted: boolean;
}
export const OnboardingSetupInstructions = ({
environmentId,
webAppUrl,
channel,
widgetSetupCompleted,
}: OnboardingSetupInstructionsProps) => {
const [activeTab, setActiveTab] = useState(tabs[0].id);
const htmlSnippetForAppSurveys = `<!-- START Formbricks Surveys -->
<script type="text/javascript">
!function(){
var apiHost = "${webAppUrl}";
var environmentId = "${environmentId}";
var userId = "testUser";
var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src=apiHost+"/api/packages/app";var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e),setTimeout(function(){window.formbricks.init({environmentId: environmentId, apiHost: apiHost, userId: userId})},500)}();
</script>
<!-- END Formbricks Surveys -->
`;
const htmlSnippetForWebsiteSurveys = `<!-- START Formbricks Surveys -->
<script type="text/javascript">
!function(){
var apiHost = "${webAppUrl}";
var environmentId = "${environmentId}";
var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src=apiHost+"/api/packages/website";var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e),setTimeout(function(){window.formbricks.init({environmentId: environmentId, apiHost: apiHost})},500)}();
</script>
<!-- END Formbricks Surveys -->
`;
const npmSnippetForAppSurveys = `
import formbricks from "@formbricks/js/app";
if (typeof window !== "undefined") {
formbricks.init({
environmentId: "${environmentId}",
apiHost: "${webAppUrl}",
userId: "testUser",
});
}
function App() {
// your own app
}
export default App;
`;
const npmSnippetForWebsiteSurveys = `
// other imports
import formbricks from "@formbricks/js/website";
if (typeof window !== "undefined") {
formbricks.init({
environmentId: "${environmentId}",
apiHost: "${webAppUrl}",
});
}
function App() {
// your own app
}
export default App;
`;
return (
<div>
<div className="flex h-14 w-full items-center justify-center rounded-md border border-slate-200 bg-white">
<TabBar
tabs={tabs}
activeId={activeTab}
setActiveId={setActiveTab}
tabStyle="button"
className="bg-slate-100"
/>
</div>
<div>
{activeTab === "npm" ? (
<div className="prose prose-slate w-full">
<CodeBlock customEditorClass="!bg-white border border-slate-200" language="sh">
npm install @formbricks/js
</CodeBlock>
<p>or</p>
<CodeBlock customEditorClass="!bg-white border border-slate-200" language="sh">
yarn add @formbricks/js
</CodeBlock>
<p className="text-sm text-slate-700">
Import Formbricks and initialize the widget in your Component (e.g. App.tsx):
</p>
<CodeBlock customEditorClass="!bg-white border border-slate-200" language="js">
{channel === "app" ? npmSnippetForAppSurveys : npmSnippetForWebsiteSurveys}
</CodeBlock>
<Button
id="onboarding-inapp-connect-read-npm-docs"
className="mt-3"
variant="secondary"
href={`https://formbricks.com/docs/${channel}-surveys/framework-guides`}
target="_blank">
Read docs
</Button>
</div>
) : activeTab === "html" ? (
<div className="prose prose-slate">
<p className="-mb-1 mt-6 text-sm text-slate-700">
Insert this code into the &lt;head&gt; tag of your website:
</p>
<div>
<CodeBlock customEditorClass="!bg-white border border-slate-200" language="js">
{channel === "app" ? htmlSnippetForAppSurveys : htmlSnippetForWebsiteSurveys}
</CodeBlock>
</div>
<div className="mt-4 flex justify-between space-x-2">
<Button
id="onboarding-inapp-connect-copy-code"
variant={widgetSetupCompleted ? "secondary" : "darkCTA"}
onClick={() => {
navigator.clipboard.writeText(
channel === "app" ? htmlSnippetForAppSurveys : htmlSnippetForWebsiteSurveys
);
toast.success("Copied to clipboard");
}}>
Copy code
</Button>
<Button
id="onboarding-inapp-connect-step-by-step-manual"
variant="secondary"
href={`https://formbricks.com/docs/${channel}-surveys/framework-guides#html`}
target="_blank">
Step by step manual
</Button>
</div>
</div>
) : null}
</div>
</div>
);
};

View File

@@ -0,0 +1,53 @@
import { InviteOrganizationMember } from "@/app/(app)/(onboarding)/environments/[environmentId]/connect/components/InviteOrganizationMember";
import { XIcon } from "lucide-react";
import { getServerSession } from "next-auth";
import { notFound, redirect } from "next/navigation";
import { authOptions } from "@formbricks/lib/authOptions";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
import { Button } from "@formbricks/ui/Button";
import { Header } from "@formbricks/ui/Header";
interface InvitePageProps {
params: {
environmentId: string;
};
}
const Page = async ({ params }: InvitePageProps) => {
const session = await getServerSession(authOptions);
if (!session || !session.user) {
return redirect(`/auth/login`);
}
const organization = await getOrganizationByEnvironmentId(params.environmentId);
if (!organization) {
throw new Error("Organization not Found");
}
const membership = await getMembershipByUserIdOrganizationId(session.user.id, organization.id);
if (!membership || (membership.role !== "owner" && membership.role !== "admin")) {
return notFound();
}
return (
<div className="flex min-h-full min-w-full flex-col items-center justify-center">
<Header
title="Invite your organization to help out"
subtitle="Ask your tech-savvy co-worker to finish the setup:"
/>
<div className="space-y-4 text-center">
<p className="text-4xl font-medium text-slate-800"></p>
<p className="text-sm text-slate-500"></p>
</div>
<InviteOrganizationMember organization={organization} environmentId={params.environmentId} />
<Button
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="minimal"
href={`/environments/${params.environmentId}/`}>
<XIcon className="h-7 w-7" strokeWidth={1.5} />
</Button>
</div>
);
};
export default Page;

View File

@@ -0,0 +1,21 @@
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import { authOptions } from "@formbricks/lib/authOptions";
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
import { AuthorizationError } from "@formbricks/types/errors";
const OnboardingLayout = async ({ children, params }) => {
const session = await getServerSession(authOptions);
if (!session || !session.user) {
return redirect(`/auth/login`);
}
const isAuthorized = await hasUserEnvironmentAccess(session.user.id, params.environmentId);
if (!isAuthorized) {
throw AuthorizationError;
}
return <div className="flex-1 bg-slate-50">{children}</div>;
};
export default OnboardingLayout;

View File

@@ -0,0 +1,65 @@
import { ConnectWithFormbricks } from "@/app/(app)/(onboarding)/environments/[environmentId]/connect/components/ConnectWithFormbricks";
import { getCustomHeadline } from "@/app/(app)/(onboarding)/lib/utils";
import { XIcon } from "lucide-react";
import { notFound } from "next/navigation";
import { WEBAPP_URL } from "@formbricks/lib/constants";
import { getEnvironment } from "@formbricks/lib/environment/service";
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
import { Button } from "@formbricks/ui/Button";
import { Header } from "@formbricks/ui/Header";
interface ConnectPageProps {
params: {
environmentId: string;
};
}
const Page = async ({ params }: ConnectPageProps) => {
const environment = await getEnvironment(params.environmentId);
if (!environment) {
throw new Error("Environment not found");
}
const product = await getProductByEnvironmentId(environment.id);
if (!product) {
throw new Error("Product not found");
}
const channel = product.config.channel;
const industry = product.config.industry;
if (!channel || !industry) {
return notFound();
}
const customHeadline = getCustomHeadline(channel, industry);
return (
<div className="flex min-h-full flex-col items-center justify-center py-10">
<Header
title={`Let's connect your ${customHeadline} with Formbricks`}
subtitle="If you don't do it now, chances are low that you will ever do it!"
/>
<div className="space-y-4 text-center">
<p className="text-4xl font-medium text-slate-800"></p>
<p className="text-sm text-slate-500"></p>
</div>
<ConnectWithFormbricks
environment={environment}
webAppUrl={WEBAPP_URL}
widgetSetupCompleted={
channel === "app" ? environment.appSetupCompleted : environment.websiteSetupCompleted
}
channel={channel}
/>
<Button
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="minimal"
href={`/environments/${environment.id}/`}>
<XIcon className="h-7 w-7" strokeWidth={1.5} />
</Button>
</div>
);
};
export default Page;

View File

@@ -0,0 +1,11 @@
import { TProductConfigChannel, TProductConfigIndustry } from "@formbricks/types/product";
export const getCustomHeadline = (channel: TProductConfigChannel, industry: TProductConfigIndustry) => {
const combinations = {
"website+eCommerce": "web shop",
"website+saas": "landing page",
"app+eCommerce": "shopping app",
"app+saas": "SaaS app",
};
return combinations[`${channel}+${industry}`] || "app";
};

View File

@@ -0,0 +1,31 @@
import { getServerSession } from "next-auth";
import { notFound, redirect } from "next/navigation";
import { authOptions } from "@formbricks/lib/authOptions";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { canUserAccessOrganization } from "@formbricks/lib/organization/auth";
import { AuthorizationError } from "@formbricks/types/errors";
import { ToasterClient } from "@formbricks/ui/ToasterClient";
const ProductOnboardingLayout = async ({ children, params }) => {
const session = await getServerSession(authOptions);
if (!session || !session.user) {
return redirect(`/auth/login`);
}
const isAuthorized = await canUserAccessOrganization(session.user.id, params.organizationId);
if (!isAuthorized) {
throw AuthorizationError;
}
const membership = await getMembershipByUserIdOrganizationId(session.user.id, params.organizationId);
if (!membership || membership.role === "viewer") return notFound();
return (
<div className="flex-1 bg-slate-50">
<ToasterClient />
{children}
</div>
);
};
export default ProductOnboardingLayout;

View File

@@ -0,0 +1,60 @@
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
import { CircleUserRoundIcon, EarthIcon, SendHorizonalIcon, XIcon } from "lucide-react";
import { getProducts } from "@formbricks/lib/product/service";
import { Button } from "@formbricks/ui/Button";
import { Header } from "@formbricks/ui/Header";
interface ChannelPageProps {
params: {
organizationId: string;
};
}
const Page = async ({ params }: ChannelPageProps) => {
const channelOptions = [
{
title: "Public website",
description: "Display surveys on public websites, well timed and targeted.",
icon: EarthIcon,
iconText: "Built for scale",
href: `/organizations/${params.organizationId}/products/new/industry?channel=website`,
},
{
title: "App with sign up",
description: "Run highly targeted surveys with any user cohort.",
icon: CircleUserRoundIcon,
iconText: "Enrich user profiles",
href: `/organizations/${params.organizationId}/products/new/industry?channel=app`,
},
{
channel: "link",
title: "Anywhere online",
description: "Create link and email surveys, reach your people anywhere.",
icon: SendHorizonalIcon,
iconText: "100% custom branding",
href: `/organizations/${params.organizationId}/products/new/industry?channel=link`,
},
];
const products = await getProducts(params.organizationId);
return (
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
<Header
title="Where do you want to survey people?"
subtitle="Get started with proven best practices 🚀"
/>
<OnboardingOptionsContainer options={channelOptions} />
{products.length >= 1 && (
<Button
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="minimal"
href={"/"}>
<XIcon className="h-7 w-7" strokeWidth={1.5} />
</Button>
)}
</div>
);
};
export default Page;

View File

@@ -0,0 +1,69 @@
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
import { HeartIcon, MonitorIcon, ShoppingCart, XIcon } from "lucide-react";
import { notFound } from "next/navigation";
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
import { getProducts } from "@formbricks/lib/product/service";
import { TProductConfigChannel } from "@formbricks/types/product";
import { Button } from "@formbricks/ui/Button";
import { Header } from "@formbricks/ui/Header";
interface IndustryPageProps {
params: {
organizationId: string;
};
searchParams: {
channel?: TProductConfigChannel;
};
}
const Page = async ({ params, searchParams }: IndustryPageProps) => {
const channel = searchParams.channel;
if (!channel) {
return notFound();
}
const products = await getProducts(params.organizationId);
const industryOptions = [
{
title: "E-Commerce",
description: "Implement proven best practices to understand why people buy.",
icon: ShoppingCart,
iconText: "B2B and B2C",
href: `/organizations/${params.organizationId}/products/new/settings?channel=${channel}&industry=eCommerce`,
},
{
title: "SaaS",
description: "Gather contextualized feedback to improve product-market fit.",
icon: MonitorIcon,
iconText: "Proven methods",
href: `/organizations/${params.organizationId}/products/new/settings?channel=${channel}&industry=saas`,
},
{
title: "Other",
description: "Universal Formricks experience with features for every industry.",
icon: HeartIcon,
iconText: "Customer insights",
href: IS_FORMBRICKS_CLOUD
? `/organizations/${params.organizationId}/products/new/survey?channel=${channel}&industry=other`
: `/organizations/${params.organizationId}/products/new/settings?channel=${channel}&industry=other`,
},
];
return (
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
<Header title="Which industry do you work for?" subtitle="Get started with proven best practices 🚀" />
<OnboardingOptionsContainer options={industryOptions} />
{products.length >= 1 && (
<Button
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="minimal"
href={"/"}>
<XIcon className="h-7 w-7" strokeWidth={1.5} />
</Button>
)}
</div>
);
};
export default Page;

View File

@@ -0,0 +1,165 @@
"use client";
import { getCustomHeadline } from "@/app/(app)/(onboarding)/lib/utils";
import { createProductAction } from "@/app/(app)/environments/[environmentId]/actions";
import { zodResolver } from "@hookform/resolvers/zod";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { toast } from "react-hot-toast";
import { PREVIEW_SURVEY } from "@formbricks/lib/styling/constants";
import {
TProductConfigChannel,
TProductConfigIndustry,
TProductUpdateInput,
ZProductUpdateInput,
} from "@formbricks/types/product";
import { Button } from "@formbricks/ui/Button";
import { ColorPicker } from "@formbricks/ui/ColorPicker";
import {
FormControl,
FormDescription,
FormError,
FormField,
FormItem,
FormLabel,
FormProvider,
} from "@formbricks/ui/Form";
import { Input } from "@formbricks/ui/Input";
import { SurveyInline } from "@formbricks/ui/Survey";
interface ProductSettingsProps {
organizationId: string;
channel: TProductConfigChannel;
industry: TProductConfigIndustry;
defaultBrandColor: string;
}
export const ProductSettings = ({
organizationId,
channel,
industry,
defaultBrandColor,
}: ProductSettingsProps) => {
const router = useRouter();
const addProduct = async (data: TProductUpdateInput) => {
try {
const product = await createProductAction(organizationId, {
...data,
config: { channel, industry },
});
// get production environment
const productionEnvironment = product.environments.find(
(environment) => environment.type === "production"
);
if (channel !== "link") {
router.push(`/environments/${productionEnvironment?.id}/connect`);
} else {
router.push(`/environments/${productionEnvironment?.id}/surveys`);
}
} catch (error) {
toast.error("Product creation failed");
console.error(error);
}
};
const form = useForm<TProductUpdateInput>({
defaultValues: {
name: "",
styling: { allowStyleOverwrite: true, brandColor: { light: defaultBrandColor } },
},
resolver: zodResolver(ZProductUpdateInput),
});
const logoUrl = form.watch("logo.url");
const brandColor = form.watch("styling.brandColor.light") ?? defaultBrandColor;
const { isSubmitting } = form.formState;
return (
<div className="mt-6 flex w-5/6 space-x-10 lg:w-2/3 2xl:w-1/2">
<div className="flex w-1/2 flex-col space-y-4">
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(addProduct)} className="w-full space-y-4">
<FormField
control={form.control}
name="styling.brandColor.light"
render={({ field, fieldState: { error } }) => (
<FormItem className="w-full space-y-4">
<div>
<FormLabel>Brand color</FormLabel>
<FormDescription>Change the brand color of the survey.</FormDescription>
</div>
<FormControl>
<div>
<ColorPicker
color={field.value || defaultBrandColor}
onChange={(color) => field.onChange(color)}
/>
{error?.message && <FormError className="text-left">{error.message}</FormError>}
</div>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="name"
render={({ field, fieldState: { error } }) => (
<FormItem className="w-full space-y-4">
<div>
<FormLabel>Product Name</FormLabel>
<FormDescription>
What is your {getCustomHeadline(channel, industry)} called ?
</FormDescription>
</div>
<FormControl>
<div>
<Input
value={field.value}
onChange={(name) => field.onChange(name)}
placeholder="Formbricks Merch Store"
className="bg-white"
autoFocus={true}
/>
{error?.message && <FormError className="text-left">{error.message}</FormError>}
</div>
</FormControl>
</FormItem>
)}
/>
<div className="flex w-full justify-end">
<Button variant="darkCTA" loading={isSubmitting} type="submit">
Next
</Button>
</div>
</form>
</FormProvider>
</div>
<div className="relative flex h-[30rem] w-1/2 flex-col items-center justify-center space-y-2 rounded-lg border bg-slate-200 shadow">
{logoUrl && (
<Image
src={logoUrl}
alt="Logo"
width={256}
height={56}
className="absolute left-2 top-2 -mb-6 h-20 w-auto max-w-64 rounded-lg border object-contain p-1"
/>
)}
<p className="text-sm text-slate-400">Preview</p>
<div className="h-3/4 w-3/4">
<SurveyInline
survey={PREVIEW_SURVEY}
styling={{ brandColor: { light: brandColor } }}
isBrandingEnabled={false}
languageCode="default"
onFileUpload={async (file) => file.name}
autoFocus={false}
/>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,60 @@
import { getCustomHeadline } from "@/app/(app)/(onboarding)/lib/utils";
import { ProductSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/products/new/settings/components/ProductSettings";
import { XIcon } from "lucide-react";
import { notFound } from "next/navigation";
import { DEFAULT_BRAND_COLOR } from "@formbricks/lib/constants";
import { getProducts } from "@formbricks/lib/product/service";
import { startsWithVowel } from "@formbricks/lib/utils/strings";
import { TProductConfigChannel, TProductConfigIndustry } from "@formbricks/types/product";
import { Button } from "@formbricks/ui/Button";
import { Header } from "@formbricks/ui/Header";
interface ProductSettingsPageProps {
params: {
organizationId: string;
};
searchParams: {
channel?: TProductConfigChannel;
industry?: TProductConfigIndustry;
};
}
const Page = async ({ params, searchParams }: ProductSettingsPageProps) => {
const channel = searchParams.channel;
const industry = searchParams.industry;
if (!channel || !industry) return notFound();
const customHeadline = getCustomHeadline(channel, industry);
const products = await getProducts(params.organizationId);
return (
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
{channel === "link" ? (
<Header
title="Match your brand, get 2x more responses."
subtitle="When people recognize your brand, they are much more likely to start and complete responses."
/>
) : (
<Header
title={`You run ${startsWithVowel(customHeadline) ? "an " + customHeadline : "a " + customHeadline}, how exciting!`}
subtitle="Get 2x more responses matching surveys with your brand and UI"
/>
)}
<ProductSettings
organizationId={params.organizationId}
channel={channel}
industry={industry}
defaultBrandColor={DEFAULT_BRAND_COLOR}
/>
{products.length >= 1 && (
<Button
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="minimal"
href={"/"}>
<XIcon className="h-7 w-7" strokeWidth={1.5} />
</Button>
)}
</div>
);
};
export default Page;

View File

@@ -0,0 +1,52 @@
"use client";
import OnboardingSurveyBg from "@/images/onboarding-survey-bg.jpg";
import Image from "next/image";
import { useRouter } from "next/navigation";
import React, { useEffect, useState } from "react";
import { TProductConfigChannel } from "@formbricks/types/product";
interface OnboardingSurveyProps {
organizationId: string;
channel: TProductConfigChannel;
}
export const OnboardingSurvey = ({ organizationId, channel }: OnboardingSurveyProps) => {
const [isIFrameVisible, setIsIFrameVisible] = useState(false);
const [fadeout, setFadeout] = useState(false);
const router = useRouter();
const handleMessageEvent = (event: MessageEvent) => {
if (event.data === "formbricksSurveyCompleted") {
setFadeout(true); // Start fade-out
setTimeout(() => {
router.push(
`/organizations/${organizationId}/products/new/settings?channel=${channel}&industry=other`
);
}, 800); // Delay the navigation until fade-out completes
}
};
useEffect(() => {
if (isIFrameVisible) {
window.addEventListener("message", handleMessageEvent, false);
return () => {
window.removeEventListener("message", handleMessageEvent, false);
};
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isIFrameVisible]);
return (
<div
className={`overflow relative flex h-[100vh] flex-col items-center justify-center ${fadeout ? "opacity-0 transition-opacity duration-1000" : "opacity-100"}`}>
<Image src={OnboardingSurveyBg} className="absolute inset-0 h-full w-full" alt="OnboardingSurveyBg" />
<div className="relative h-[60vh] w-[50vh] overflow-auto">
<iframe
onLoad={() => setIsIFrameVisible(true)}
src="https://app.formbricks.com/s/clxcwr22p0cwlpvgekzdab2x5?embed=true"
className="absolute left-0 top-0 h-full w-full overflow-visible border-0"></iframe>
</div>
</div>
);
};

View File

@@ -0,0 +1,23 @@
import { OnboardingSurvey } from "@/app/(app)/(onboarding)/organizations/[organizationId]/products/new/survey/components/OnboardingSurvey";
import { notFound } from "next/navigation";
import { TProductConfigChannel, TProductConfigIndustry } from "@formbricks/types/product";
interface OnboardingSurveyPageProps {
params: {
organizationId: string;
};
searchParams: {
channel?: TProductConfigChannel;
industry?: TProductConfigIndustry;
};
}
const Page = async ({ params, searchParams }: OnboardingSurveyPageProps) => {
const channel = searchParams.channel;
const industry = searchParams.industry;
if (!channel || !industry) return notFound();
return <OnboardingSurvey organizationId={params.organizationId} channel={channel} />;
};
export default Page;

View File

@@ -0,0 +1,61 @@
"use server";
import { getServerSession } from "next-auth";
import { sendInviteMemberEmail } from "@formbricks/email";
import { hasOrganizationAuthority } from "@formbricks/lib/auth";
import { authOptions } from "@formbricks/lib/authOptions";
import { INVITE_DISABLED } from "@formbricks/lib/constants";
import { inviteUser } from "@formbricks/lib/invite/service";
import { verifyUserRoleAccess } from "@formbricks/lib/organization/auth";
import { AuthenticationError } from "@formbricks/types/errors";
import { TMembershipRole } from "@formbricks/types/memberships";
export const inviteOrganizationMemberAction = async (
organizationId: string,
email: string,
role: TMembershipRole,
inviteMessage: string
) => {
const session = await getServerSession(authOptions);
if (!session) {
throw new AuthenticationError("Not authenticated");
}
const isUserAuthorized = await hasOrganizationAuthority(session.user.id, organizationId);
if (INVITE_DISABLED) {
throw new AuthenticationError("Invite disabled");
}
if (!isUserAuthorized) {
throw new AuthenticationError("Not authorized");
}
const { hasCreateOrUpdateMembersAccess } = await verifyUserRoleAccess(organizationId, session.user.id);
if (!hasCreateOrUpdateMembersAccess) {
throw new AuthenticationError("Not authorized");
}
const invite = await inviteUser({
organizationId,
invitee: {
email,
name: "",
role,
},
});
if (invite) {
await sendInviteMemberEmail(
invite.id,
email,
session.user.name ?? "",
"",
true, // is onboarding invite
inviteMessage
);
}
return invite;
};

View File

@@ -0,0 +1,41 @@
import { LucideProps } from "lucide-react";
import Link from "next/link";
import { ForwardRefExoticComponent, RefAttributes } from "react";
import { OptionCard } from "@formbricks/ui/OptionCard";
interface OnboardingOptionsContainerProps {
options: {
title: string;
description: string;
icon: ForwardRefExoticComponent<Omit<LucideProps, "ref"> & RefAttributes<SVGSVGElement>>;
iconText: string;
href: string;
}[];
}
export const OnboardingOptionsContainer = ({ options }: OnboardingOptionsContainerProps) => {
return (
<div className="grid w-5/6 grid-cols-3 gap-8 text-center lg:w-2/3">
{options.map((option, index) => {
const Icon = option.icon;
return (
<Link href={option.href}>
<OptionCard
size="md"
key={index}
title={option.title}
description={option.description}
loading={false}>
<div className="flex flex-col items-center">
<Icon className="h-16 w-16 text-slate-600" strokeWidth={0.5} />
<p className="mt-4 w-fit rounded-xl bg-slate-200 px-4 text-sm text-slate-700">
{option.iconText}
</p>
</div>
</OptionCard>
</Link>
);
})}
</div>
);
};

View File

@@ -40,9 +40,7 @@ const EnvLayout = async ({ children, params }) => {
environmentId={params.environmentId}
organizationId={organization.id}
organizationName={organization.name}
inAppSurveyBillingStatus={organization.billing.features.inAppSurvey.status}
linkSurveyBillingStatus={organization.billing.features.linkSurvey.status}
userTargetingBillingStatus={organization.billing.features.userTargeting.status}
organizationBilling={organization.billing}
/>
<FormbricksClient session={session} />
<ToasterClient />

View File

@@ -1,6 +1,7 @@
"use client";
import { PlusIcon, XCircleIcon } from "lucide-react";
import Link from "next/link";
import { useMemo, useState } from "react";
import { toast } from "react-hot-toast";
import { extractLanguageCodes } from "@formbricks/lib/i18n/utils";
@@ -26,6 +27,7 @@ interface FileUploadFormProps {
setSelectedLanguageCode: (languageCode: string) => void;
isInvalid: boolean;
attributeClasses: TAttributeClass[];
isFormbricksCloud: boolean;
}
export const FileUploadQuestionForm = ({
@@ -38,8 +40,10 @@ export const FileUploadQuestionForm = ({
selectedLanguageCode,
setSelectedLanguageCode,
attributeClasses,
isFormbricksCloud,
}: FileUploadFormProps): JSX.Element => {
const [extension, setExtension] = useState("");
const [isMaxSizeError, setMaxSizeError] = useState(false);
const {
billingInfo,
error: billingInfoError,
@@ -103,7 +107,7 @@ export const FileUploadQuestionForm = ({
return 10;
}
if (billingInfo.features.linkSurvey.status === "active") {
if (billingInfo.plan !== "free") {
// 1GB in MB
return 1024;
}
@@ -111,6 +115,12 @@ export const FileUploadQuestionForm = ({
return 10;
}, [billingInfo, billingInfoError, billingInfoLoading]);
const handleMaxSizeInMBToggle = (checked: boolean) => {
const defaultMaxSizeInMB = isFormbricksCloud ? maxSizeInMBLimit : 1024;
updateQuestion(questionIdx, { maxSizeInMB: checked ? defaultMaxSizeInMB : undefined });
};
return (
<form>
<QuestionFormInput
@@ -172,7 +182,7 @@ export const FileUploadQuestionForm = ({
<AdvancedOptionToggle
isChecked={!!question.maxSizeInMB}
onToggle={(checked) => updateQuestion(questionIdx, { maxSizeInMB: checked ? 10 : undefined })}
onToggle={handleMaxSizeInMBToggle}
htmlId="maxFileSize"
title="Max file size"
description="Limit the maximum file size."
@@ -189,8 +199,9 @@ export const FileUploadQuestionForm = ({
onChange={(e) => {
const parsedValue = parseInt(e.target.value, 10);
if (parsedValue > maxSizeInMBLimit) {
if (isFormbricksCloud && parsedValue > maxSizeInMBLimit) {
toast.error(`Max file size limit is ${maxSizeInMBLimit} MB`);
setMaxSizeError(true);
updateQuestion(questionIdx, { maxSizeInMB: maxSizeInMBLimit });
return;
}
@@ -201,6 +212,17 @@ export const FileUploadQuestionForm = ({
/>
MB
</p>
{isMaxSizeError && (
<p className="text-xs text-red-500">
Max file size limit is {maxSizeInMBLimit} MB. If you need more, please{" "}
<Link
className="underline"
target="_blank"
href={`/environments/${localSurvey.environmentId}/settings/billing`}>
upgrade your plan.
</Link>
</p>
)}
</label>
</AdvancedOptionToggle>

View File

@@ -1,11 +1,12 @@
"use client";
import * as Collapsible from "@radix-ui/react-collapsible";
import { AlertCircleIcon, CheckIcon, EarthIcon, LinkIcon, MonitorIcon, SmartphoneIcon } from "lucide-react";
import { AlertCircleIcon, BlocksIcon, CheckIcon, EarthIcon, LinkIcon, MonitorIcon } from "lucide-react";
import Link from "next/link";
import { useEffect, useState } from "react";
import { cn } from "@formbricks/lib/cn";
import { TEnvironment } from "@formbricks/types/environment";
import { TProduct } from "@formbricks/types/product";
import { TSegment } from "@formbricks/types/segment";
import { TSurvey, TSurveyType } from "@formbricks/types/surveys";
import { Badge } from "@formbricks/ui/Badge";
@@ -16,17 +17,18 @@ interface HowToSendCardProps {
localSurvey: TSurvey;
setLocalSurvey: (survey: TSurvey | ((TSurvey: TSurvey) => TSurvey)) => void;
environment: TEnvironment;
product: TProduct;
}
export const HowToSendCard = ({ localSurvey, setLocalSurvey, environment }: HowToSendCardProps) => {
export const HowToSendCard = ({ localSurvey, setLocalSurvey, environment, product }: HowToSendCardProps) => {
const [open, setOpen] = useState(false);
const [widgetSetupCompleted, setWidgetSetupCompleted] = useState(false);
const [appSetupCompleted, setAppSetupCompleted] = useState(false);
const [websiteSetupCompleted, setWebsiteSetupCompleted] = useState(false);
useEffect(() => {
if (environment && environment.widgetSetupCompleted) {
setWidgetSetupCompleted(true);
} else {
setWidgetSetupCompleted(false);
if (environment) {
setAppSetupCompleted(environment.appSetupCompleted);
setWebsiteSetupCompleted(environment.websiteSetupCompleted);
}
}, [environment]);
@@ -76,7 +78,8 @@ export const HowToSendCard = ({ localSurvey, setLocalSurvey, environment }: HowT
icon: EarthIcon,
description: "Run targeted surveys on public websites.",
comingSoon: false,
alert: !widgetSetupCompleted,
alert: !websiteSetupCompleted,
hide: product.config.channel && product.config.channel !== "website",
},
{
id: "app",
@@ -84,7 +87,8 @@ export const HowToSendCard = ({ localSurvey, setLocalSurvey, environment }: HowT
icon: MonitorIcon,
description: "Embed a survey in your web app to collect responses with user identification.",
comingSoon: false,
alert: !widgetSetupCompleted,
alert: !appSetupCompleted,
hide: product.config.channel && product.config.channel !== "app",
},
{
id: "link",
@@ -93,24 +97,35 @@ export const HowToSendCard = ({ localSurvey, setLocalSurvey, environment }: HowT
description: "Share a link to a survey page or embed it in a web page or email.",
comingSoon: false,
alert: false,
hide: false,
},
{
id: "mobile",
name: "Mobile App Survey",
icon: SmartphoneIcon,
description: "Survey users inside a mobile app (iOS & Android).",
id: "headless",
name: "Headless Survey",
icon: BlocksIcon,
description: "Use Formbricks API only and create your own frontend experience.",
comingSoon: true,
alert: false,
hide: false,
},
];
const promotedFeaturesString =
product.config.channel === "website"
? "app"
: product.config.channel === "app"
? "website"
: product.config.channel === "link"
? "app or website"
: "";
return (
<Collapsible.Root
open={open}
onOpenChange={setOpen}
className={cn(
open ? "" : "hover:bg-slate-50",
"w-full space-y-2 rounded-lg border border-slate-300 bg-white "
"w-full space-y-2 rounded-lg border border-slate-300 bg-white"
)}>
<Collapsible.CollapsibleTrigger
asChild
@@ -125,7 +140,7 @@ export const HowToSendCard = ({ localSurvey, setLocalSurvey, environment }: HowT
</div>
<div>
<p className="font-semibold text-slate-800">Survey Type</p>
<p className="mt-1 text-sm text-slate-500">Choose between website, in-app or link survey.</p>
<p className="mt-1 text-sm text-slate-500">Choose where to run the survey.</p>
</div>
</div>
</Collapsible.CollapsibleTrigger>
@@ -137,66 +152,79 @@ export const HowToSendCard = ({ localSurvey, setLocalSurvey, environment }: HowT
value={localSurvey.type}
onValueChange={setSurveyType}
className="flex flex-col space-y-3">
{options.map((option) => (
<Label
key={option.id}
htmlFor={option.id}
className={cn(
"flex w-full items-center rounded-lg border bg-slate-50 p-4",
option.comingSoon
? "border-slate-200 bg-slate-50/50"
: option.id === localSurvey.type
? "border-brand-dark cursor-pointer bg-slate-50"
: "cursor-pointer bg-slate-50"
)}
id={`howToSendCardOption-${option.id}`}>
<RadioGroupItem
value={option.id}
id={option.id}
className="aria-checked:border-brand-dark mx-5 disabled:border-slate-400 aria-checked:border-2"
disabled={option.comingSoon}
/>
<div className=" inline-flex items-center">
<option.icon className="mr-4 h-8 w-8 text-slate-500" />
<div>
<div className="inline-flex items-center">
<p
className={cn(
"font-semibold",
option.comingSoon ? "text-slate-500" : "text-slate-800"
)}>
{option.name}
</p>
{option.comingSoon && (
<Badge text="coming soon" size="normal" type="success" className="ml-2" />
{options
.filter((option) => !Boolean(option.hide))
.map((option) => (
<Label
key={option.id}
htmlFor={option.id}
className={cn(
"flex w-full items-center rounded-lg border bg-slate-50 p-4",
option.comingSoon
? "border-slate-200 bg-slate-50/50"
: option.id === localSurvey.type
? "border-brand-dark cursor-pointer bg-slate-50"
: "cursor-pointer bg-slate-50"
)}
id={`howToSendCardOption-${option.id}`}>
<RadioGroupItem
value={option.id}
id={option.id}
className="aria-checked:border-brand-dark mx-5 disabled:border-slate-400 aria-checked:border-2"
disabled={option.comingSoon}
/>
<div className="inline-flex items-center">
<option.icon className="mr-4 h-8 w-8 text-slate-500" />
<div>
<div className="inline-flex items-center">
<p
className={cn(
"font-semibold",
option.comingSoon ? "text-slate-500" : "text-slate-800"
)}>
{option.name}
</p>
{option.comingSoon && (
<Badge text="coming soon" size="normal" type="success" className="ml-2" />
)}
</div>
<p className="mt-2 text-xs font-normal text-slate-600">{option.description}</p>
{option.alert && (
<div className="mt-2 flex items-center space-x-3 rounded-lg border border-amber-200 bg-amber-50 px-4 py-2">
<AlertCircleIcon className="h-5 w-5 text-amber-500" />
<div className="text-amber-800">
<p className="text-xs font-semibold">
Your {option.id} is not yet connected to Formbricks.
</p>
<p className="text-xs font-normal">
<Link
href={`/environments/${environment.id}/product/${option.id}-connection`}
className="underline hover:text-amber-900"
target="_blank">
Connect Formbricks
</Link>{" "}
and launch surveys in your {option.id}.
</p>
</div>
</div>
)}
</div>
<p className="mt-2 text-xs font-normal text-slate-600">{option.description}</p>
{option.alert && (
<div className="mt-2 flex items-center space-x-3 rounded-lg border border-amber-200 bg-amber-50 px-4 py-2">
<AlertCircleIcon className="h-5 w-5 text-amber-500" />
<div className=" text-amber-800">
<p className="text-xs font-semibold">
Your app is not yet connected to Formbricks.
</p>
<p className="text-xs font-normal">
<Link
href={`/environments/${environment.id}/product/setup`}
className="underline hover:text-amber-900"
target="_blank">
Connect Formbricks
</Link>{" "}
and launch surveys in your app or website.
</p>
</div>
</div>
)}
</div>
</div>
</Label>
))}
</Label>
))}
</RadioGroup>
</div>
{promotedFeaturesString && (
<div className="mt-2 flex items-center space-x-3 rounded-b-lg border border-slate-200 bg-slate-50/50 px-4 py-2">
<AlertCircleIcon className="h-5 w-5 text-slate-500" />
<div className="text-slate-500">
<p className="text-xs">
You can also use Formbricks to run {promotedFeaturesString} surveys. Create a new product for
your {promotedFeaturesString} to use this feature.
</p>
</div>
</div>
)}
</Collapsible.CollapsibleContent>
</Collapsible.Root>
);

View File

@@ -46,6 +46,7 @@ interface QuestionCardProps {
isInvalid: boolean;
attributeClasses: TAttributeClass[];
addQuestion: (question: any, index?: number) => void;
isFormbricksCloud: boolean;
}
export const QuestionCard = ({
@@ -65,6 +66,7 @@ export const QuestionCard = ({
isInvalid,
attributeClasses,
addQuestion,
isFormbricksCloud,
}: QuestionCardProps) => {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: question.id,
@@ -166,13 +168,13 @@ export const QuestionCard = ({
className="flex-1 rounded-r-lg border border-slate-200">
<Collapsible.CollapsibleTrigger
asChild
className={cn(open ? "" : " ", "flex cursor-pointer justify-between p-4 hover:bg-slate-50")}>
className={cn(open ? "" : " ", "flex cursor-pointer justify-between gap-4 p-4 hover:bg-slate-50")}>
<div>
<div className="inline-flex">
<div className="flex grow">
<div className="-ml-0.5 mr-3 h-6 min-w-[1.5rem] text-slate-400">
{QUESTIONS_ICON_MAP[question.type]}
</div>
<div>
<div className="grow" dir="auto">
<p className="text-sm font-semibold">
{recallToHeadline(
question.headline,
@@ -333,6 +335,7 @@ export const QuestionCard = ({
setSelectedLanguageCode={setSelectedLanguageCode}
isInvalid={isInvalid}
attributeClasses={attributeClasses}
isFormbricksCloud={isFormbricksCloud}
/>
) : question.type === TSurveyQuestionTypeEnum.Cal ? (
<CalQuestionForm

View File

@@ -17,7 +17,7 @@ import {
DropdownMenuTrigger,
} from "@formbricks/ui/DropdownMenu";
interface QuestionDropdownProps {
interface QuestionMenuProps {
questionIdx: number;
lastQuestion: boolean;
duplicateQuestion: (questionIdx: number) => void;
@@ -39,7 +39,7 @@ export const QuestionMenu = ({
question,
updateQuestion,
addQuestion,
}: QuestionDropdownProps) => {
}: QuestionMenuProps) => {
const [logicWarningModal, setLogicWarningModal] = useState(false);
const [changeToType, setChangeToType] = useState(question.type);

View File

@@ -19,6 +19,7 @@ interface QuestionsDraggableProps {
internalQuestionIdMap: Record<string, string>;
attributeClasses: TAttributeClass[];
addQuestion: (question: any, index?: number) => void;
isFormbricksCloud: boolean;
}
export const QuestionsDroppable = ({
@@ -36,6 +37,7 @@ export const QuestionsDroppable = ({
internalQuestionIdMap,
attributeClasses,
addQuestion,
isFormbricksCloud,
}: QuestionsDraggableProps) => {
return (
<div className="group mb-5 grid w-full gap-5">
@@ -59,6 +61,7 @@ export const QuestionsDroppable = ({
isInvalid={invalidQuestions ? invalidQuestions.includes(question.id) : false}
attributeClasses={attributeClasses}
addQuestion={addQuestion}
isFormbricksCloud={isFormbricksCloud}
/>
))}
</SortableContext>

View File

@@ -46,7 +46,7 @@ export const QuestionsAudienceTabs = ({
}, [isStylingTabVisible]);
return (
<div className="fixed z-20 flex h-14 w-full items-center justify-center border bg-white md:w-1/2">
<div className="fixed z-30 flex h-14 w-full items-center justify-center border bg-white md:w-1/2">
<nav className="flex h-full items-center space-x-4" aria-label="Tabs">
{tabsComputed.map((tab) => (
<button

View File

@@ -365,6 +365,7 @@ export const QuestionsView = ({
internalQuestionIdMap={internalQuestionIdMap}
attributeClasses={attributeClasses}
addQuestion={addQuestion}
isFormbricksCloud={isFormbricksCloud}
/>
</DndContext>

View File

@@ -104,7 +104,8 @@ export const RecontactOptionsCard = ({
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="recontactOptionsCardTrigger">
<div className="inline-flex px-4 py-4">
<div className="flex items-center pl-2 pr-5">
<CheckIcon
@@ -146,7 +147,7 @@ export const RecontactOptionsCard = ({
<RadioGroupItem
value={option.id}
id={option.name}
className="aria-checked:border-brand-dark mx-5 disabled:border-slate-400 aria-checked:border-2"
className="aria-checked:border-brand-dark mx-5 disabled:border-slate-400 aria-checked:border-2"
/>
<div>
<p className="font-semibold text-slate-700">{option.name}</p>

View File

@@ -70,11 +70,8 @@ export const SelectQuestionChoice = ({
return (
<div className="flex w-full gap-2" ref={setNodeRef} style={style}>
{/* drag handle */}
<div
className={cn("flex items-center", choice.id === "other" && "invisible")}
{...listeners}
{...attributes}>
<GripVerticalIcon className="mt-3 h-4 w-4 cursor-move text-slate-400" />
<div className={cn("mt-6", choice.id === "other" && "invisible")} {...listeners} {...attributes}>
<GripVerticalIcon className="h-4 w-4 cursor-move text-slate-400" />
</div>
<div className="flex w-full space-x-2">

View File

@@ -3,6 +3,7 @@ import { TActionClass } from "@formbricks/types/actionClasses";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import { TEnvironment } from "@formbricks/types/environment";
import { TMembershipRole } from "@formbricks/types/memberships";
import { TProduct } from "@formbricks/types/product";
import { TSegment } from "@formbricks/types/segment";
import { TSurvey } from "@formbricks/types/surveys";
import { HowToSendCard } from "./HowToSendCard";
@@ -23,6 +24,7 @@ interface SettingsViewProps {
membershipRole?: TMembershipRole;
isUserTargetingAllowed?: boolean;
isFormbricksCloud: boolean;
product: TProduct;
}
export const SettingsView = ({
@@ -36,12 +38,18 @@ export const SettingsView = ({
membershipRole,
isUserTargetingAllowed = false,
isFormbricksCloud,
product,
}: SettingsViewProps) => {
const isWebSurvey = localSurvey.type === "website" || localSurvey.type === "app";
return (
<div className="mt-12 space-y-3 p-5">
<HowToSendCard localSurvey={localSurvey} setLocalSurvey={setLocalSurvey} environment={environment} />
<HowToSendCard
localSurvey={localSurvey}
setLocalSurvey={setLocalSurvey}
environment={environment}
product={product}
/>
{localSurvey.type === "app" ? (
!isUserTargetingAllowed ? (

View File

@@ -192,6 +192,7 @@ export const SurveyEditor = ({
membershipRole={membershipRole}
isUserTargetingAllowed={isUserTargetingAllowed}
isFormbricksCloud={isFormbricksCloud}
product={localProduct}
/>
)}
</main>

View File

@@ -61,6 +61,7 @@ export const UpdateQuestionId = ({
onChange={(e) => {
setCurrentValue(e.target.value);
}}
dir="auto"
disabled={localSurvey.status !== "draft" && !question.isDraft}
className={`h-10 ${isInputInvalid ? "border-red-300 focus:border-red-300" : ""}`}
/>

View File

@@ -1,18 +0,0 @@
"use server";
import { getServerSession } from "next-auth";
import { authOptions } from "@formbricks/lib/authOptions";
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
import { createSurvey } from "@formbricks/lib/survey/service";
import { AuthorizationError } from "@formbricks/types/errors";
import { TSurveyInput } from "@formbricks/types/surveys";
export const createSurveyAction = async (environmentId: string, surveyBody: TSurveyInput) => {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
const isAuthorized = await hasUserEnvironmentAccess(session.user.id, environmentId);
if (!isAuthorized) throw new AuthorizationError("Not authorized");
return await createSurvey(environmentId, surveyBody);
};

View File

@@ -4,8 +4,8 @@ import { MenuBar } from "@/app/(app)/(survey-editor)/environments/[environmentId
import { useState } from "react";
import { customSurvey } from "@formbricks/lib/templates";
import type { TEnvironment } from "@formbricks/types/environment";
import type { TProduct } from "@formbricks/types/product";
import type { TTemplate } from "@formbricks/types/templates";
import type { TProduct, TProductConfigChannel, TProductConfigIndustry } from "@formbricks/types/product";
import type { TTemplate, TTemplateRole } from "@formbricks/types/templates";
import { TUser } from "@formbricks/types/user";
import { PreviewSurvey } from "@formbricks/ui/PreviewSurvey";
import { SearchBox } from "@formbricks/ui/SearchBox";
@@ -17,13 +17,14 @@ type TemplateContainerWithPreviewProps = {
product: TProduct;
environment: TEnvironment;
user: TUser;
prefilledFilters: (TProductConfigChannel | TProductConfigIndustry | TTemplateRole | null)[];
};
export const TemplateContainerWithPreview = ({
environmentId,
product,
environment,
user,
prefilledFilters,
}: TemplateContainerWithPreviewProps) => {
const initialTemplate = customSurvey;
const [activeTemplate, setActiveTemplate] = useState<TTemplate>(initialTemplate);
@@ -35,7 +36,7 @@ export const TemplateContainerWithPreview = ({
<MenuBar />
<div className="relative z-0 flex flex-1 overflow-hidden">
<div className="flex-1 flex-col overflow-auto bg-slate-50">
<div className="ml-6 mt-6 flex flex-col items-center justify-between md:flex-row md:items-start">
<div className="mb-3 ml-6 mt-6 flex flex-col items-center justify-between md:flex-row md:items-end">
<h1 className="text-2xl font-bold text-slate-800">Create a new survey</h1>
<div className="px-6">
<SearchBox
@@ -51,7 +52,6 @@ export const TemplateContainerWithPreview = ({
</div>
<TemplateList
environmentId={environmentId}
environment={environment}
product={product}
user={user}
@@ -60,6 +60,7 @@ export const TemplateContainerWithPreview = ({
setActiveQuestionId(template.preset.questions[0].id);
setActiveTemplate(template);
}}
prefilledFilters={prefilledFilters}
/>
</div>
<aside className="group hidden flex-1 flex-shrink-0 items-center justify-center overflow-hidden border-l border-slate-100 bg-slate-50 md:flex md:flex-col">

View File

@@ -2,9 +2,22 @@ import { getServerSession } from "next-auth";
import { authOptions } from "@formbricks/lib/authOptions";
import { getEnvironment } from "@formbricks/lib/environment/service";
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
import { TProductConfigChannel, TProductConfigIndustry } from "@formbricks/types/product";
import { TTemplateRole } from "@formbricks/types/templates";
import { TemplateContainerWithPreview } from "./components/TemplateContainer";
const Page = async ({ params }) => {
interface SurveyTemplateProps {
params: {
environmentId: string;
};
searchParams: {
channel?: TProductConfigChannel;
industry?: TProductConfigIndustry;
role?: TTemplateRole;
};
}
const Page = async ({ params, searchParams }: SurveyTemplateProps) => {
const session = await getServerSession(authOptions);
const environmentId = params.environmentId;
@@ -25,12 +38,15 @@ const Page = async ({ params }) => {
throw new Error("Environment not found");
}
const prefilledFilters = [product.config.channel, product.config.industry, searchParams.role ?? null];
return (
<TemplateContainerWithPreview
environmentId={environmentId}
user={session.user}
environment={environment}
product={product}
prefilledFilters={prefilledFilters}
/>
);
};

View File

@@ -1,7 +1,9 @@
import { PeopleSecondaryNavigation } from "@/app/(app)/environments/[environmentId]/(people)/people/components/PeopleSecondaryNavigation";
import { CircleHelpIcon } from "lucide-react";
import { Metadata } from "next";
import { notFound } from "next/navigation";
import { getAttributeClasses } from "@formbricks/lib/attributeClass/service";
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
import { Button } from "@formbricks/ui/Button";
import { PageContentWrapper } from "@formbricks/ui/PageContentWrapper";
import { PageHeader } from "@formbricks/ui/PageHeader";
@@ -14,6 +16,18 @@ export const metadata: Metadata = {
const Page = async ({ params }) => {
let attributeClasses = await getAttributeClasses(params.environmentId);
const product = await getProductByEnvironmentId(params.environmentId);
if (!product) {
throw new Error("Product not found");
}
const currentProductChannel = product.config.channel ?? null;
if (currentProductChannel && currentProductChannel !== "app") {
return notFound();
}
const HowToAddAttributesButton = (
<Button
size="sm"

View File

@@ -1,4 +1,5 @@
import { ActivityTimeline } from "@/app/(app)/environments/[environmentId]/(people)/people/[personId]/components/ActivityTimeline";
import { getAdvancedTargetingPermission } from "@formbricks/ee/lib/service";
import { getActionsByPersonId } from "@formbricks/lib/action/service";
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
import { getEnvironment } from "@formbricks/lib/environment/service";
@@ -19,7 +20,7 @@ export const ActivitySection = async ({
// On Formbricks Cloud only render the timeline if the user targeting feature is booked
const isUserTargetingEnabled = IS_FORMBRICKS_CLOUD
? organization.billing.features.userTargeting.status === "active"
? await getAdvancedTargetingPermission(organization)
: true;
const [environment, actions] = await Promise.all([

View File

@@ -1,3 +1,5 @@
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
import { TProductConfigChannel } from "@formbricks/types/product";
import { SecondaryNavigation } from "@formbricks/ui/SecondaryNavigation";
interface PeopleSegmentsTabsProps {
@@ -6,7 +8,23 @@ interface PeopleSegmentsTabsProps {
loading?: boolean;
}
export const PeopleSecondaryNavigation = ({ activeId, environmentId, loading }: PeopleSegmentsTabsProps) => {
export const PeopleSecondaryNavigation = async ({
activeId,
environmentId,
loading,
}: PeopleSegmentsTabsProps) => {
let currentProductChannel: TProductConfigChannel = null;
if (!loading && environmentId) {
const product = await getProductByEnvironmentId(environmentId);
if (!product) {
throw new Error("Product not found");
}
currentProductChannel = product.config.channel ?? null;
}
const navigation = [
{
id: "people",
@@ -22,6 +40,8 @@ export const PeopleSecondaryNavigation = ({ activeId, environmentId, loading }:
id: "attributes",
label: "Attributes",
href: `/environments/${environmentId}/attributes`,
// hide attributes tab if it's being used in the loading state or if the product's channel is website or link
hidden: !!(!loading && currentProductChannel && currentProductChannel !== "app"),
},
];

View File

@@ -243,7 +243,7 @@ export const BasicSegmentSettings = ({
handleUpdateSegment();
}}
disabled={isSaveDisabled}>
Save Changes
Save changes
</Button>
</div>

View File

@@ -5,18 +5,14 @@ import { getServerSession } from "next-auth";
import { getIsMultiOrgEnabled } from "@formbricks/ee/lib/service";
import { authOptions } from "@formbricks/lib/authOptions";
import { SHORT_URL_BASE, WEBAPP_URL } from "@formbricks/lib/constants";
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
import { createMembership } from "@formbricks/lib/membership/service";
import { createOrganization, getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
import { createMembership, getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { createOrganization } from "@formbricks/lib/organization/service";
import { createProduct } from "@formbricks/lib/product/service";
import { createShortUrl } from "@formbricks/lib/shortUrl/service";
import { updateUser } from "@formbricks/lib/user/service";
import {
AuthenticationError,
AuthorizationError,
OperationNotAllowedError,
ResourceNotFoundError,
} from "@formbricks/types/errors";
import { AuthenticationError, AuthorizationError, OperationNotAllowedError } from "@formbricks/types/errors";
import { TProduct, TProductUpdateInput } from "@formbricks/types/product";
import { TUserNotificationSettings } from "@formbricks/types/user";
export const createShortUrlAction = async (url: string) => {
const session = await getServerSession(authOptions);
@@ -54,7 +50,7 @@ export const createOrganizationAction = async (organizationName: string): Promis
name: "My Product",
});
const updatedNotificationSettings = {
const updatedNotificationSettings: TUserNotificationSettings = {
...session.user.notificationSettings,
alert: {
...session.user.notificationSettings?.alert,
@@ -63,6 +59,9 @@ export const createOrganizationAction = async (organizationName: string): Promis
...session.user.notificationSettings?.weeklySummary,
[product.id]: true,
},
unsubscribedOrganizationIds: Array.from(
new Set([...(session.user.notificationSettings?.unsubscribedOrganizationIds || []), newOrganization.id])
),
};
await updateUser(session.user.id, {
@@ -72,19 +71,19 @@ export const createOrganizationAction = async (organizationName: string): Promis
return newOrganization;
};
export const createProductAction = async (environmentId: string, productName: string) => {
export const createProductAction = async (
organizationId: string,
productInput: TProductUpdateInput
): Promise<TProduct> => {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
if (!session) throw new AuthorizationError("Not authenticated");
const isAuthorized = await hasUserEnvironmentAccess(session.user.id, environmentId);
if (!isAuthorized) throw new AuthorizationError("Not authorized");
const membership = await getMembershipByUserIdOrganizationId(session.user.id, organizationId);
if (!membership || membership.role === "viewer") {
throw new AuthorizationError("Product creation not allowed");
}
const organization = await getOrganizationByEnvironmentId(environmentId);
if (!organization) throw new ResourceNotFoundError("Organization from environment", environmentId);
const product = await createProduct(organization.id, {
name: productName,
});
const product = await createProduct(organizationId, productInput);
const updatedNotificationSettings = {
...session.user.notificationSettings,
alert: {
@@ -100,9 +99,5 @@ export const createProductAction = async (environmentId: string, productName: st
notificationSettings: updatedNotificationSettings,
});
// get production environment
const productionEnvironment = product.environments.find((environment) => environment.type === "production");
if (!productionEnvironment) throw new ResourceNotFoundError("Production environment", environmentId);
return productionEnvironment;
return product;
};

View File

@@ -5,6 +5,7 @@ import { useEffect, useState } from "react";
import { convertDateTimeStringShort } from "@formbricks/lib/time";
import { capitalizeFirstLetter } from "@formbricks/lib/utils/strings";
import { TActionClass } from "@formbricks/types/actionClasses";
import { TProductConfigChannel } from "@formbricks/types/product";
import { ErrorComponent } from "@formbricks/ui/ErrorComponent";
import { Label } from "@formbricks/ui/Label";
import { LoadingSpinner } from "@formbricks/ui/LoadingSpinner";
@@ -19,12 +20,14 @@ interface ActivityTabProps {
actionClass: TActionClass;
environmentId: string;
isUserTargetingEnabled: boolean;
currentProductChannel: TProductConfigChannel;
}
export const EventActivityTab = ({
actionClass,
environmentId,
isUserTargetingEnabled,
currentProductChannel,
}: ActivityTabProps) => {
// const { eventClass, isLoadingEventClass, isErrorEventClass } = useEventClass(environmentId, actionClass.id);
@@ -36,6 +39,8 @@ export const EventActivityTab = ({
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const shouldShowActivity = isUserTargetingEnabled && currentProductChannel !== "website";
useEffect(() => {
setLoading(true);
@@ -48,9 +53,9 @@ export const EventActivityTab = ({
numEventsLast7DaysData,
activeInactiveSurveys,
] = await Promise.all([
isUserTargetingEnabled ? getActionCountInLastHourAction(actionClass.id, environmentId) : 0,
isUserTargetingEnabled ? getActionCountInLast24HoursAction(actionClass.id, environmentId) : 0,
isUserTargetingEnabled ? getActionCountInLast7DaysAction(actionClass.id, environmentId) : 0,
shouldShowActivity ? getActionCountInLastHourAction(actionClass.id, environmentId) : 0,
shouldShowActivity ? getActionCountInLast24HoursAction(actionClass.id, environmentId) : 0,
shouldShowActivity ? getActionCountInLast7DaysAction(actionClass.id, environmentId) : 0,
getActiveInactiveSurveysAction(actionClass.id, environmentId),
]);
setNumEventsLastHour(numEventsLastHourData);
@@ -66,7 +71,7 @@ export const EventActivityTab = ({
};
updateState();
}, [actionClass.id, environmentId, isUserTargetingEnabled]);
}, [actionClass.id, environmentId, shouldShowActivity]);
if (loading) return <LoadingSpinner />;
if (error) return <ErrorComponent />;
@@ -74,9 +79,9 @@ export const EventActivityTab = ({
return (
<div className="grid grid-cols-3 pb-2">
<div className="col-span-2 space-y-4 pr-6">
{isUserTargetingEnabled && (
{shouldShowActivity && (
<div>
<Label className="text-slate-500">Ocurrances</Label>
<Label className="text-slate-500">Occurrences</Label>
<div className="mt-1 grid w-fit grid-cols-3 rounded-lg border-slate-100 bg-slate-50">
<div className="border-r border-slate-200 px-4 py-2 text-center">
<p className="font-bold text-slate-800">{numEventsLastHour}</p>

View File

@@ -3,6 +3,7 @@
import { useState } from "react";
import { useMembershipRole } from "@formbricks/lib/membership/hooks/useMembershipRole";
import { TActionClass } from "@formbricks/types/actionClasses";
import { TProductConfigChannel } from "@formbricks/types/product";
import { ErrorComponent } from "@formbricks/ui/ErrorComponent";
import { ActionDetailModal } from "./ActionDetailModal";
@@ -11,6 +12,7 @@ interface ActionClassesTableProps {
actionClasses: TActionClass[];
children: [JSX.Element, JSX.Element[]];
isUserTargetingEnabled: boolean;
currentProductChannel: TProductConfigChannel;
}
export const ActionClassesTable = ({
@@ -18,6 +20,7 @@ export const ActionClassesTable = ({
actionClasses,
children: [TableHeading, actionRows],
isUserTargetingEnabled,
currentProductChannel,
}: ActionClassesTableProps) => {
const [isActionDetailModalOpen, setActionDetailModalOpen] = useState(false);
const { membershipRole, error } = useMembershipRole(environmentId);
@@ -60,6 +63,7 @@ export const ActionClassesTable = ({
actionClass={activeActionClass}
membershipRole={membershipRole}
isUserTargetingEnabled={isUserTargetingEnabled}
currentProductChannel={currentProductChannel}
/>
)}
</>

View File

@@ -1,6 +1,7 @@
import { Code2Icon, MousePointerClickIcon, SparklesIcon } from "lucide-react";
import { TActionClass } from "@formbricks/types/actionClasses";
import { TMembershipRole } from "@formbricks/types/memberships";
import { TProductConfigChannel } from "@formbricks/types/product";
import { ModalWithTabs } from "@formbricks/ui/ModalWithTabs";
import { EventActivityTab } from "./ActionActivityTab";
import { ActionSettingsTab } from "./ActionSettingsTab";
@@ -13,6 +14,7 @@ interface ActionDetailModalProps {
actionClasses: TActionClass[];
membershipRole?: TMembershipRole;
isUserTargetingEnabled: boolean;
currentProductChannel: TProductConfigChannel;
}
export const ActionDetailModal = ({
@@ -23,6 +25,7 @@ export const ActionDetailModal = ({
actionClasses,
membershipRole,
isUserTargetingEnabled,
currentProductChannel,
}: ActionDetailModalProps) => {
const tabs = [
{
@@ -32,6 +35,7 @@ export const ActionDetailModal = ({
actionClass={actionClass}
environmentId={environmentId}
isUserTargetingEnabled={isUserTargetingEnabled}
currentProductChannel={currentProductChannel}
/>
),
},

View File

@@ -3,9 +3,12 @@ import { ActionClassDataRow } from "@/app/(app)/environments/[environmentId]/act
import { ActionTableHeading } from "@/app/(app)/environments/[environmentId]/actions/components/ActionTableHeading";
import { AddActionModal } from "@/app/(app)/environments/[environmentId]/actions/components/AddActionModal";
import { Metadata } from "next";
import { notFound } from "next/navigation";
import { getAdvancedTargetingPermission } from "@formbricks/ee/lib/service";
import { getActionClasses } from "@formbricks/lib/actionClass/service";
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
import { PageContentWrapper } from "@formbricks/ui/PageContentWrapper";
import { PageHeader } from "@formbricks/ui/PageHeader";
@@ -14,8 +17,9 @@ export const metadata: Metadata = {
};
const Page = async ({ params }) => {
const [actionClasses, organization] = await Promise.all([
const [actionClasses, product, organization] = await Promise.all([
getActionClasses(params.environmentId),
getProductByEnvironmentId(params.environmentId),
getOrganizationByEnvironmentId(params.environmentId),
]);
@@ -23,9 +27,14 @@ const Page = async ({ params }) => {
throw new Error("Organization not found");
}
const currentProductChannel = product?.config.channel ?? null;
if (currentProductChannel === "link") {
return notFound();
}
// On Formbricks Cloud only render the timeline if the user targeting feature is booked
const isUserTargetingEnabled = IS_FORMBRICKS_CLOUD
? organization.billing.features.userTargeting.status === "active"
? await getAdvancedTargetingPermission(organization)
: true;
const renderAddActionButton = () => (
@@ -38,7 +47,8 @@ const Page = async ({ params }) => {
<ActionClassesTable
environmentId={params.environmentId}
actionClasses={actionClasses}
isUserTargetingEnabled={isUserTargetingEnabled}>
isUserTargetingEnabled={isUserTargetingEnabled}
currentProductChannel={currentProductChannel}>
<ActionTableHeading />
{actionClasses.map((actionClass) => (
<ActionClassDataRow key={actionClass.id} actionClass={actionClass} />

View File

@@ -1,96 +0,0 @@
"use client";
import { createProductAction } from "@/app/(app)/environments/[environmentId]/actions";
import { PlusCircleIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { Button } from "@formbricks/ui/Button";
import { Input } from "@formbricks/ui/Input";
import { Label } from "@formbricks/ui/Label";
import { Modal } from "@formbricks/ui/Modal";
interface AddProductModalProps {
environmentId: string;
open: boolean;
setOpen: (v: boolean) => void;
}
export const AddProductModal = ({ environmentId, open, setOpen }: AddProductModalProps) => {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [productName, setProductName] = useState("");
const isProductNameValid = productName.trim() !== "";
const { register, handleSubmit } = useForm();
const submitProduct = async (data: { name: string }) => {
data.name = data.name.trim();
if (!isProductNameValid) return;
try {
setLoading(true);
const newEnv = await createProductAction(environmentId, data.name);
toast.success("Product created successfully!");
router.push(`/environments/${newEnv.id}/`);
setOpen(false);
} catch (error) {
console.error(error);
toast.error(`Error: Unable to save product information`);
} finally {
setLoading(false);
}
};
return (
<Modal open={open} setOpen={setOpen} noPadding closeOnOutsideClick={false}>
<div className="flex h-full flex-col rounded-lg">
<div className="rounded-t-lg bg-slate-100">
<div className="flex items-center justify-between p-6">
<div className="flex items-center space-x-2">
<div className="mr-1.5 h-10 w-10 text-slate-500">
<PlusCircleIcon className="h-5 w-5" />
</div>
<div>
<div className="text-xl font-medium text-slate-700">Add Product</div>
<div className="text-sm text-slate-500">Create a new product for your organization.</div>
</div>
</div>
</div>
</div>
<form onSubmit={handleSubmit(submitProduct)}>
<div className="flex w-full justify-between space-y-4 rounded-lg p-6">
<div className="grid w-full gap-x-2">
<div>
<Label>Name</Label>
<Input
autoFocus
placeholder="e.g. My New Product"
{...register("name", { required: true })}
value={productName}
onChange={(e) => setProductName(e.target.value)}
/>
</div>
</div>
</div>
<div className="flex justify-end border-t border-slate-200 p-6">
<div className="flex space-x-2">
<Button
type="button"
variant="minimal"
onClick={() => {
setOpen(false);
}}>
Cancel
</Button>
<Button variant="darkCTA" type="submit" loading={loading} disabled={!isProductNameValid}>
Add product
</Button>
</div>
</div>
</form>
</div>
</Modal>
);
};

View File

@@ -12,6 +12,7 @@ import {
import { getProducts } from "@formbricks/lib/product/service";
import { DevEnvironmentBanner } from "@formbricks/ui/DevEnvironmentBanner";
import { ErrorComponent } from "@formbricks/ui/ErrorComponent";
import { LimitsReachedBanner } from "@formbricks/ui/LimitsReachedBanner";
interface EnvironmentLayoutProps {
environmentId: string;
@@ -42,9 +43,15 @@ export const EnvironmentLayout = async ({ environmentId, session, children }: En
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
const currentProductChannel =
products.find((product) => product.id === environment.productId)?.config.channel ?? null;
return (
<div className="flex h-screen min-h-screen flex-col overflow-hidden">
<DevEnvironmentBanner environment={environment} />
{IS_FORMBRICKS_CLOUD && <LimitsReachedBanner organization={organization} />}
<div className="flex h-full">
<MainNavigation
environment={environment}
@@ -57,7 +64,11 @@ export const EnvironmentLayout = async ({ environmentId, session, children }: En
isMultiOrgEnabled={isMultiOrgEnabled}
/>
<div id="mainContent" className="flex-1 overflow-y-auto bg-slate-50">
<TopControlBar environment={environment} environments={environments} />
<TopControlBar
environment={environment}
environments={environments}
currentProductChannel={currentProductChannel}
/>
<div className="mt-14">{children}</div>
</div>
</div>

View File

@@ -49,7 +49,6 @@ import {
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@formbricks/ui/DropdownMenu";
import { AddProductModal } from "./AddProductModal";
interface NavigationProps {
environment: TEnvironment;
@@ -77,7 +76,6 @@ export const MainNavigation = ({
const [currentOrganizationName, setCurrentOrganizationName] = useState("");
const [currentOrganizationId, setCurrentOrganizationId] = useState("");
const [showAddProductModal, setShowAddProductModal] = useState(false);
const [showCreateOrganizationModal, setShowCreateOrganizationModal] = useState(false);
const [isCollapsed, setIsCollapsed] = useState(true);
const [isTextVisible, setIsTextVisible] = useState(true);
@@ -127,6 +125,10 @@ export const MainNavigation = ({
router.push(`/organizations/${organizationId}/`);
};
const handleAddProduct = (organizationId: string) => {
router.push(`/organizations/${organizationId}/products/new/channel`);
};
const mainNavigation = useMemo(
() => [
{
@@ -150,7 +152,7 @@ export const MainNavigation = ({
href: `/environments/${environment.id}/actions`,
icon: MousePointerClick,
isActive: pathname?.includes("/actions") || pathname?.includes("/actions"),
isHidden: false,
isHidden: product?.config.channel === "link",
},
{
name: "Integrations",
@@ -328,7 +330,7 @@ export const MainNavigation = ({
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
{!isViewer && (
<DropdownMenuItem onClick={() => setShowAddProductModal(true)} className="rounded-lg">
<DropdownMenuItem onClick={() => handleAddProduct(organization.id)} className="rounded-lg">
<PlusIcon className="mr-2 h-4 w-4" />
<span>Add product</span>
</DropdownMenuItem>
@@ -464,11 +466,6 @@ export const MainNavigation = ({
open={showCreateOrganizationModal}
setOpen={(val) => setShowCreateOrganizationModal(val)}
/>
<AddProductModal
open={showAddProductModal}
setOpen={(val) => setShowAddProductModal(val)}
environmentId={environment.id}
/>
</>
);
};

View File

@@ -4,7 +4,7 @@ import type { Session } from "next-auth";
import { usePostHog } from "posthog-js/react";
import { useEffect } from "react";
import { env } from "@formbricks/lib/env";
import { TSubscriptionStatus } from "@formbricks/types/organizations";
import { TOrganizationBilling } from "@formbricks/types/organizations";
const posthogEnabled = env.NEXT_PUBLIC_POSTHOG_API_KEY && env.NEXT_PUBLIC_POSTHOG_API_HOST;
@@ -13,9 +13,7 @@ interface PosthogIdentifyProps {
environmentId?: string;
organizationId?: string;
organizationName?: string;
inAppSurveyBillingStatus?: TSubscriptionStatus;
linkSurveyBillingStatus?: TSubscriptionStatus;
userTargetingBillingStatus?: TSubscriptionStatus;
organizationBilling?: TOrganizationBilling;
}
export const PosthogIdentify = ({
@@ -23,9 +21,7 @@ export const PosthogIdentify = ({
environmentId,
organizationId,
organizationName,
inAppSurveyBillingStatus,
linkSurveyBillingStatus,
userTargetingBillingStatus,
organizationBilling,
}: PosthogIdentifyProps) => {
const posthog = usePostHog();
@@ -43,22 +39,13 @@ export const PosthogIdentify = ({
if (organizationId) {
posthog.group("organization", organizationId, {
name: organizationName,
inAppSurveyBillingStatus,
linkSurveyBillingStatus,
userTargetingBillingStatus,
plan: organizationBilling?.plan,
responseLimit: organizationBilling?.limits.monthly.responses,
miuLimit: organizationBilling?.limits.monthly.miu,
});
}
}
}, [
posthog,
session.user,
environmentId,
organizationId,
organizationName,
inAppSurveyBillingStatus,
linkSurveyBillingStatus,
userTargetingBillingStatus,
]);
}, [posthog, session.user, environmentId, organizationId, organizationName, organizationBilling]);
return null;
};

View File

@@ -2,18 +2,28 @@ import { TopControlButtons } from "@/app/(app)/environments/[environmentId]/comp
import { WidgetStatusIndicator } from "@/app/(app)/environments/[environmentId]/components/WidgetStatusIndicator";
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
import { TEnvironment } from "@formbricks/types/environment";
import { TProductConfigChannel } from "@formbricks/types/product";
interface SideBarProps {
environment: TEnvironment;
environments: TEnvironment[];
currentProductChannel: TProductConfigChannel;
}
export const TopControlBar = ({ environment, environments }: SideBarProps) => {
export const TopControlBar = ({ environment, environments, currentProductChannel }: SideBarProps) => {
return (
<div className="fixed inset-0 top-0 z-30 flex h-14 w-full items-center justify-end bg-slate-50 px-6">
<div className="shadow-xs z-10">
<div className="flex w-fit space-x-2 py-2">
<WidgetStatusIndicator environment={environment} type="mini" />
<div className="flex w-fit items-center space-x-2 py-2">
{currentProductChannel && currentProductChannel !== "link" && (
<WidgetStatusIndicator environment={environment} size="mini" type={currentProductChannel} />
)}
{!currentProductChannel && (
<>
<WidgetStatusIndicator environment={environment} size="mini" type="website" />
<WidgetStatusIndicator environment={environment} size="mini" type="app" />
</>
)}
<TopControlButtons
environment={environment}
environments={environments}

View File

@@ -6,29 +6,30 @@ import { Label } from "@formbricks/ui/Label";
interface WidgetStatusIndicatorProps {
environment: TEnvironment;
type: "large" | "mini";
size: "large" | "mini";
type: "app" | "website";
}
export const WidgetStatusIndicator = ({ environment, type }: WidgetStatusIndicatorProps) => {
export const WidgetStatusIndicator = ({ environment, size, type }: WidgetStatusIndicatorProps) => {
const stati = {
notImplemented: {
icon: AlertTriangleIcon,
title: "Connect Formbricks to your app or website.",
subtitle:
"Your app or website is not yet connected with Formbricks. To run in-app surveys follow the setup guide.",
shortText: "Connect Formbricks to your app or website",
title: `Your ${type} is not yet connected.`,
subtitle: `Connect your ${type} with Formbricks to get started. To run ${type === "app" ? "in-app" : "website"} surveys follow the setup guide.`,
shortText: `Connect your ${type} with Formbricks`,
},
running: {
icon: CheckIcon,
title: "Receiving data.",
subtitle: "Your app or website is connected with Formbricks.",
shortText: "Connected",
title: "Receiving data 💃🕺",
subtitle: `Your ${type} is connected with Formbricks.`,
shortText: `${type === "app" ? "App" : "Website"} connected`,
},
};
const setupStatus = type === "app" ? environment.appSetupCompleted : environment.websiteSetupCompleted;
let status: "notImplemented" | "running" | "issue";
if (environment.widgetSetupCompleted) {
if (setupStatus) {
status = "running";
} else {
status = "notImplemented";
@@ -36,19 +37,19 @@ export const WidgetStatusIndicator = ({ environment, type }: WidgetStatusIndicat
const currentStatus = stati[status];
if (type === "large") {
if (size === "large") {
return (
<div
className={clsx(
"flex flex-col items-center justify-center space-y-2 rounded-lg py-6 text-center",
status === "notImplemented" && "bg-slate-100",
status === "running" && "bg-green-100"
"flex flex-col items-center justify-center space-y-2 rounded-lg border py-6 text-center",
status === "notImplemented" && "border-slate-200 bg-slate-100",
status === "running" && "border-emerald-200 bg-emerald-100"
)}>
<div
className={clsx(
"flex h-12 w-12 items-center justify-center rounded-full bg-white p-2",
status === "notImplemented" && "text-slate-700",
status === "running" && "text-green-700"
"flex h-12 w-12 items-center justify-center rounded-full border bg-white p-2",
status === "notImplemented" && "border-slate-200 text-slate-700",
status === "running" && "border-emerald-200 text-emerald-700"
)}>
<currentStatus.icon />
</div>
@@ -57,25 +58,27 @@ export const WidgetStatusIndicator = ({ environment, type }: WidgetStatusIndicat
</div>
);
}
if (type === "mini") {
if (size === "mini") {
return (
<Link href={`/environments/${environment.id}/product/setup`}>
<div className="group flex justify-center">
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
<Label className="group-hover:cursor-pointer group-hover:underline">
{currentStatus.shortText}
</Label>
<div
className={clsx(
"h-5 w-5 rounded-full",
status === "notImplemented" && "text-amber-600",
status === "running" && "bg-green-100 text-green-700"
)}>
<currentStatus.icon className="h-5 w-5" />
<div className="flex gap-2">
<Link href={`/environments/${environment.id}/product/${type}-connection`}>
<div className="group flex justify-center">
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
{status === "running" ? (
<span className="relative flex h-3 w-3">
<span className="animate-ping-slow absolute inline-flex h-full w-full rounded-full bg-emerald-500 opacity-75"></span>
<span className="relative inline-flex h-3 w-3 rounded-full bg-emerald-500"></span>
</span>
) : (
<AlertTriangleIcon className="h-[14px] w-[14px] text-amber-600" />
)}
<Label className="group-hover:cursor-pointer group-hover:underline">
{currentStatus.shortText}
</Label>
</div>
</div>
</div>
</Link>
</Link>
</div>
);
} else {
return null;

View File

@@ -64,19 +64,28 @@ const Page = async ({ params }) => {
const isN8nIntegrationConnected = isIntegrationConnected("n8n");
const isSlackIntegrationConnected = isIntegrationConnected("slack");
const widgetSetupCompleted = environment?.appSetupCompleted || environment?.websiteSetupCompleted;
const bothSetupCompleted = environment?.appSetupCompleted && environment?.websiteSetupCompleted;
const integrationCards = [
{
docsHref: "https://formbricks.com/docs/getting-started/framework-guides#next-js",
docsText: "Docs",
docsNewTab: true,
connectHref: `/environments/${environmentId}/product/setup`,
connectHref: `/environments/${environmentId}/product/app-connection`,
connectText: "Connect",
connectNewTab: false,
label: "Javascript Widget",
description: "Integrate Formbricks into your Webapp",
icon: <Image src={JsLogo} alt="Javascript Logo" />,
connected: environment?.widgetSetupCompleted,
statusText: environment?.widgetSetupCompleted ? "Connected" : "Not Connected",
connected: widgetSetupCompleted,
statusText: bothSetupCompleted
? "app & website connected"
: environment?.appSetupCompleted
? "app Connected"
: environment?.websiteSetupCompleted
? "website connected"
: "Not Connected",
},
{
docsHref: "https://formbricks.com/docs/integrations/zapier",

View File

@@ -1,10 +1,12 @@
import { EnvironmentLayout } from "@/app/(app)/environments/[environmentId]/components/EnvironmentLayout";
import { ResponseFilterProvider } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import { notFound, redirect } from "next/navigation";
import { authOptions } from "@formbricks/lib/authOptions";
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
import { AuthorizationError } from "@formbricks/types/errors";
import { ToasterClient } from "@formbricks/ui/ToasterClient";
import { FormbricksClient } from "../../components/FormbricksClient";
@@ -24,6 +26,13 @@ const EnvLayout = async ({ children, params }) => {
if (!organization) {
throw new Error("Organization not found");
}
const product = await getProductByEnvironmentId(params.environmentId);
if (!product) {
throw new Error("Product not found");
}
const membership = await getMembershipByUserIdOrganizationId(session.user.id, organization.id);
if (!membership) return notFound();
return (
<>
@@ -33,9 +42,7 @@ const EnvLayout = async ({ children, params }) => {
environmentId={params.environmentId}
organizationId={organization.id}
organizationName={organization.name}
inAppSurveyBillingStatus={organization.billing.features.inAppSurvey.status}
linkSurveyBillingStatus={organization.billing.features.linkSurvey.status}
userTargetingBillingStatus={organization.billing.features.userTargeting.status}
organizationBilling={organization.billing}
/>
<FormbricksClient session={session} />
<ToasterClient />

View File

@@ -38,7 +38,7 @@ const Loading = () => {
},
{
title: "Your EnvironmentId",
description: "This Id uniquely identifies this Formbricks environment.",
description: "This id uniquely identifies this Formbricks environment.",
skeletonLines: [{ classes: "h-6 w-4/6 rounded-full" }],
},
{
@@ -88,10 +88,18 @@ const Loading = () => {
current: pathname?.includes("/api-keys"),
},
{
id: "setup",
label: "Setup Guide",
id: "website-connection",
label: "Website Connection",
icon: <ListChecksIcon className="h-5 w-5" />,
current: pathname?.includes("/setup"),
current: pathname?.includes("/website-connection"),
hidden: true,
},
{
id: "app-connection",
label: "App Connection",
icon: <ListChecksIcon className="h-5 w-5" />,
current: pathname?.includes("/app-connection"),
hidden: true,
},
];
@@ -105,13 +113,13 @@ const Loading = () => {
<div
key={navElem.id}
className={cn(
navElem.id === "setup"
navElem.id === "app-connection"
? "border-brand-dark border-b-2 font-semibold text-slate-900"
: "border-transparent text-slate-500 transition-all duration-150 ease-in-out hover:border-slate-300 hover:text-slate-700",
"flex h-full items-center border-b-2 px-3 text-sm font-medium",
navElem.hidden && "hidden"
)}
aria-current={navElem.id === "setup" ? "page" : undefined}>
aria-current={navElem.id === "app-connection" ? "page" : undefined}>
{navElem.label}
</div>
))}

View File

@@ -1,19 +1,22 @@
import { WidgetStatusIndicator } from "@/app/(app)/environments/[environmentId]/components/WidgetStatusIndicator";
import { EnvironmentIdField } from "@/app/(app)/environments/[environmentId]/product/(setup)/components/EnvironmentIdField";
import { SetupInstructions } from "@/app/(app)/environments/[environmentId]/product/(setup)/components/SetupInstructions";
import { ProductConfigNavigation } from "@/app/(app)/environments/[environmentId]/product/components/ProductConfigNavigation";
import { notFound } from "next/navigation";
import { getMultiLanguagePermission } from "@formbricks/ee/lib/service";
import { IS_FORMBRICKS_CLOUD, WEBAPP_URL } from "@formbricks/lib/constants";
import { getEnvironment } from "@formbricks/lib/environment/service";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
import { EnvironmentNotice } from "@formbricks/ui/EnvironmentNotice";
import { PageContentWrapper } from "@formbricks/ui/PageContentWrapper";
import { PageHeader } from "@formbricks/ui/PageHeader";
import { SettingsCard } from "../../settings/components/SettingsCard";
import { EnvironmentIdField } from "./components/EnvironmentIdField";
import { SetupInstructions } from "./components/SetupInstructions";
import { SettingsCard } from "../../../settings/components/SettingsCard";
const Page = async ({ params }) => {
const [environment, organization] = await Promise.all([
const [environment, product, organization] = await Promise.all([
getEnvironment(params.environmentId),
getProductByEnvironmentId(params.environmentId),
getOrganizationByEnvironmentId(params.environmentId),
]);
@@ -26,26 +29,32 @@ const Page = async ({ params }) => {
}
const isMultiLanguageAllowed = await getMultiLanguagePermission(organization);
const currentProductChannel = product?.config.channel ?? null;
if (currentProductChannel && currentProductChannel !== "app") {
return notFound();
}
return (
<PageContentWrapper>
<PageHeader pageTitle="Configuration">
<ProductConfigNavigation
environmentId={params.environmentId}
activeId="setup"
activeId="app-connection"
isMultiLanguageAllowed={isMultiLanguageAllowed}
productChannel={currentProductChannel}
/>
</PageHeader>
<div className="space-y-4">
<EnvironmentNotice environmentId={params.environmentId} subPageUrl="/product/setup" />
<EnvironmentNotice environmentId={params.environmentId} subPageUrl="/product/app-connection" />
<SettingsCard
title="Widget Status"
description="Check if the Formbricks widget is alive and kicking.">
{environment && <WidgetStatusIndicator environment={environment} type="large" />}
title="App Connection Status"
description="Check if your app is successfully connected with Formbricks. Reload page to recheck.">
{environment && <WidgetStatusIndicator environment={environment} size="large" type="app" />}
</SettingsCard>
<SettingsCard
title="Your EnvironmentId"
description="This Id uniquely identifies this Formbricks environment.">
description="This id uniquely identifies this Formbricks environment.">
<EnvironmentIdField environmentId={params.environmentId} />
</SettingsCard>
<SettingsCard
@@ -56,6 +65,7 @@ const Page = async ({ params }) => {
environmentId={params.environmentId}
webAppUrl={WEBAPP_URL}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
type="app"
/>
</SettingsCard>
</div>

View File

@@ -21,12 +21,14 @@ interface SetupInstructionsProps {
environmentId: string;
webAppUrl: string;
isFormbricksCloud: boolean;
type: "app" | "website";
}
export const SetupInstructions = ({
environmentId,
webAppUrl,
isFormbricksCloud,
type,
}: SetupInstructionsProps) => {
const [activeTab, setActiveTab] = useState(tabs[0].id);
@@ -35,23 +37,24 @@ export const SetupInstructions = ({
<TabBar tabs={tabs} activeId={activeTab} setActiveId={setActiveTab} />
<div className="px-6 py-5">
{activeTab === "npm" ? (
<div className="prose prose-slate">
<p className="text-lg font-semibold text-slate-800">Step 1: Install with NPM or Yarn</p>
<div className="prose prose-slate prose-p:my-2 prose-p:text-sm prose-p:text-slate-600 prose-h4:text-slate-800 prose-h4:pt-2">
<h4>Step 1: Install with pnpm, npm or yarn</h4>
<CodeBlock language="sh">pnpm install @formbricks/js</CodeBlock>
<p>or</p>
<CodeBlock language="sh">npm install @formbricks/js</CodeBlock>
<p>or</p>
<CodeBlock language="sh">yarn add @formbricks/js</CodeBlock>
<p className="pt-4 text-lg font-semibold text-slate-800">Step 2: Initialize widget</p>
<h4>Step 2: Initialize widget</h4>
<p>Import Formbricks and initialize the widget in your Component (e.g. App.tsx):</p>
<CodeBlock language="js">{`import formbricks from "@formbricks/js/website";
<CodeBlock language="js">{`import formbricks from "@formbricks/js/${type}";
if (typeof window !== "undefined") {
formbricks.init({
environmentId: "${environmentId}",
environmentId: "${environmentId}", ${type === "app" ? `\n userId: "<user-id>",` : ""}
apiHost: "${webAppUrl}",
});
}`}</CodeBlock>
<ul className="list-disc">
<ul className="list-disc text-sm">
<li>
<span className="font-semibold">environmentId:</span> Used to identify the correct
environment: {environmentId} is yours.
@@ -60,20 +63,26 @@ if (typeof window !== "undefined") {
<span className="font-semibold">apiHost:</span> This is the URL of your Formbricks backend.
</li>
</ul>
<p className="text-lg font-semibold text-slate-800">You&apos;re done 🎉</p>
<h4>Step 3: Debug mode</h4>
<p>
Your app now communicates with Formbricks - sending events, and loading surveys automatically!
Switch on the debug mode by appending <i>?formbricksDebug=true</i> to the URL where you load the
Formbricks SDK. Open the browser console to see the logs.{" "}
<Link
className="decoration-brand-dark"
href="https://formbricks.com/docs/developer-docs/app-survey-sdk#debug-mode"
target="_blank">
Read docs.
</Link>{" "}
</p>
<h4>You&apos;re done 🎉</h4>
<p>
Your {type} now communicates with Formbricks - sending events, and loading surveys
automatically!
</p>
<ul className="list-disc text-slate-700">
<ul className="list-disc text-sm text-slate-700">
<li>
<span className="font-semibold">Does your widget work? </span>
<span>Scroll to the top!</span>
</li>
<li>
<span className="font-semibold">
Need a more detailed setup guide for React, Next.js or Vue.js?
</span>{" "}
<span>Need a more detailed setup guide for React, Next.js or Vue.js?</span>{" "}
<Link
className="decoration-brand-dark"
href="https://formbricks.com/docs/website-surveys/quickstart"
@@ -82,22 +91,20 @@ if (typeof window !== "undefined") {
</Link>
</li>
<li>
<span className="font-semibold">Have a problem?</span>{" "}
<span>Not working?</span>{" "}
<Link className="decoration-brand-dark" href="https://formbricks.com/discord" target="_blank">
Join Discord
</Link>{" "}
or{" "}
<Link
className="decoration-brand-dark"
target="_blank"
href="https://github.com/formbricks/formbricks/issues">
Open an issue on GitHub
open an issue on GitHub
</Link>{" "}
or{" "}
<Link className="decoration-brand-dark" href="https://formbricks.com/discord" target="_blank">
join Discord.
</Link>
</li>
<li>
<span className="font-semibold">
Want to learn how to add user attributes, custom events and more?
</span>{" "}
<span>Want to learn how to add user attributes, custom events and more?</span>{" "}
<Link
className="decoration-brand-dark"
href="https://formbricks.com/docs/attributes/why"
@@ -108,22 +115,33 @@ if (typeof window !== "undefined") {
</ul>
</div>
) : activeTab === "html" ? (
<div className="prose prose-slate">
<p className="text-lg font-semibold text-slate-800">Step 1: Copy and paste code</p>
<div className="prose prose-slate prose-p:my-2 prose-p:text-sm prose-p:text-slate-600 prose-h4:text-slate-800 prose-h4:pt-2">
<h4>Step 1: Copy and paste code</h4>
<p>
Insert this code into the <code>{`<head>`}</code> tag of your website:
Insert this code into the <code>{`<head>`}</code> tag of your {type}:
</p>
<CodeBlock language="js">{`<!-- START Formbricks Surveys -->
<script type="text/javascript">
!function(){var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src="https://unpkg.com/@formbricks/js@^1.6.5/dist/index.umd.js";var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e),setTimeout(function(){window.formbricks.init({environmentId: "${environmentId}", apiHost: "${window.location.protocol}//${window.location.host}"})},500)}();
!function(){var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src="${webAppUrl}/api/packages/${type}";var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e),setTimeout(function(){window.formbricks.init({environmentId: "${environmentId}", ${type === "app" ? `\n userId: "<user-id>",` : ""} apiHost: "${window.location.protocol}//${window.location.host}"})},500)}();
</script>
<!-- END Formbricks Surveys -->`}</CodeBlock>
<p className="text-lg font-semibold text-slate-800">You&apos;re done 🎉</p>
<h4>Step 2: Debug mode</h4>
<p>
Your app now communicates with Formbricks - sending events, and loading surveys automatically!
Switch on the debug mode by appending <i>?formbricksDebug=true</i> to the URL where you load the
Formbricks SDK. Open the browser console to see the logs.{" "}
<Link
className="decoration-brand-dark"
href="https://formbricks.com/docs/developer-docs/app-survey-sdk#debug-mode"
target="_blank">
Read docs.
</Link>{" "}
</p>
<ul className="list-disc text-slate-700">
<h4>You&apos;re done 🎉</h4>
<p>
Your {type} now communicates with Formbricks - sending events, and loading surveys
automatically!
</p>
<ul className="list-disc text-sm text-slate-700">
<li>
<span className="font-semibold">Does your widget work? </span>
<span>Scroll to the top!</span>

View File

@@ -0,0 +1,139 @@
"use client";
import { BrushIcon, KeyIcon, LanguagesIcon, ListChecksIcon, TagIcon, UsersIcon } from "lucide-react";
import { usePathname } from "next/navigation";
import { cn } from "@formbricks/lib/cn";
import { PageContentWrapper } from "@formbricks/ui/PageContentWrapper";
import { PageHeader } from "@formbricks/ui/PageHeader";
const LoadingCard = ({ title, description, skeletonLines }) => {
return (
<div className="w-full max-w-4xl rounded-xl border border-slate-200 bg-white py-4 text-left shadow-sm">
<div className="grid content-center border-b border-slate-200 px-4 pb-4 text-left text-slate-900">
<h3 className="text-lg font-medium leading-6">{title}</h3>
<p className="mt-1 text-sm text-slate-500">{description}</p>
</div>
<div className="w-full">
<div className="rounded-lg px-4 ">
{skeletonLines.map((line, index) => (
<div key={index} className="mt-4">
<div
className={`flex animate-pulse flex-col items-center justify-center space-y-2 rounded-lg bg-slate-200 py-6 text-center ${line.classes}`}></div>
</div>
))}
</div>
</div>
</div>
);
};
const Loading = () => {
const pathname = usePathname();
const cards = [
{
title: "Widget Status",
description: "Check if the Formbricks widget is alive and kicking.",
skeletonLines: [{ classes: " h-44 max-w-full rounded-md" }],
},
{
title: "Your EnvironmentId",
description: "This id uniquely identifies this Formbricks environment.",
skeletonLines: [{ classes: "h-6 w-4/6 rounded-full" }],
},
{
title: "How to setup",
description: "Follow these steps to setup the Formbricks widget within your app",
skeletonLines: [
{ classes: "h-6 w-24 rounded-full" },
{ classes: "h-4 w-60 rounded-full" },
{ classes: "h-4 w-60 rounded-full" },
{ classes: "h-6 w-24 rounded-full" },
{ classes: "h-4 w-60 rounded-full" },
{ classes: "h-4 w-60 rounded-full" },
],
},
];
let navigation = [
{
id: "general",
label: "General",
icon: <UsersIcon className="h-5 w-5" />,
current: pathname?.includes("/general"),
},
{
id: "look",
label: "Look & Feel",
icon: <BrushIcon className="h-5 w-5" />,
current: pathname?.includes("/look"),
},
{
id: "languages",
label: "Survey Languages",
icon: <LanguagesIcon className="h-5 w-5" />,
hidden: true,
current: pathname?.includes("/languages"),
},
{
id: "tags",
label: "Tags",
icon: <TagIcon className="h-5 w-5" />,
current: pathname?.includes("/tags"),
},
{
id: "api-keys",
label: "API Keys",
icon: <KeyIcon className="h-5 w-5" />,
current: pathname?.includes("/api-keys"),
},
{
id: "website-connection",
label: "Website Connection",
icon: <ListChecksIcon className="h-5 w-5" />,
current: pathname?.includes("/website-connection"),
hidden: true,
},
{
id: "app-connection",
label: "App Connection",
icon: <ListChecksIcon className="h-5 w-5" />,
current: pathname?.includes("/app-connection"),
hidden: true,
},
];
return (
<div>
<PageContentWrapper>
<PageHeader pageTitle="Configuration">
<div className="grid h-10 w-full grid-cols-[auto,1fr] ">
<nav className="flex h-full min-w-full items-center space-x-4" aria-label="Tabs">
{navigation.map((navElem) => (
<div
key={navElem.id}
className={cn(
navElem.id === "website-connection"
? "border-brand-dark border-b-2 font-semibold text-slate-900"
: "border-transparent text-slate-500 transition-all duration-150 ease-in-out hover:border-slate-300 hover:text-slate-700",
"flex h-full items-center border-b-2 px-3 text-sm font-medium",
navElem.hidden && "hidden"
)}
aria-current={navElem.id === "website-connection" ? "page" : undefined}>
{navElem.label}
</div>
))}
</nav>
<div className="justify-self-end"></div>
</div>
</PageHeader>
<div className="mt-4 flex max-w-4xl animate-pulse items-center space-y-4 rounded-lg border bg-blue-50 p-6 text-sm text-blue-900 shadow-sm md:space-y-0 md:text-base"></div>
{cards.map((card, index) => (
<LoadingCard key={index} {...card} />
))}
</PageContentWrapper>
</div>
);
};
export default Loading;

View File

@@ -0,0 +1,76 @@
import { WidgetStatusIndicator } from "@/app/(app)/environments/[environmentId]/components/WidgetStatusIndicator";
import { EnvironmentIdField } from "@/app/(app)/environments/[environmentId]/product/(setup)/components/EnvironmentIdField";
import { SetupInstructions } from "@/app/(app)/environments/[environmentId]/product/(setup)/components/SetupInstructions";
import { ProductConfigNavigation } from "@/app/(app)/environments/[environmentId]/product/components/ProductConfigNavigation";
import { notFound } from "next/navigation";
import { getMultiLanguagePermission } from "@formbricks/ee/lib/service";
import { IS_FORMBRICKS_CLOUD, WEBAPP_URL } from "@formbricks/lib/constants";
import { getEnvironment } from "@formbricks/lib/environment/service";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
import { EnvironmentNotice } from "@formbricks/ui/EnvironmentNotice";
import { PageContentWrapper } from "@formbricks/ui/PageContentWrapper";
import { PageHeader } from "@formbricks/ui/PageHeader";
import { SettingsCard } from "../../../settings/components/SettingsCard";
const Page = async ({ params }) => {
const [environment, product, organization] = await Promise.all([
getEnvironment(params.environmentId),
getProductByEnvironmentId(params.environmentId),
getOrganizationByEnvironmentId(params.environmentId),
]);
if (!environment) {
throw new Error("Environment not found");
}
if (!organization) {
throw new Error("Organization not found");
}
const isMultiLanguageAllowed = await getMultiLanguagePermission(organization);
const currentProductChannel = product?.config.channel ?? null;
if (currentProductChannel && currentProductChannel !== "website") {
return notFound();
}
return (
<PageContentWrapper>
<PageHeader pageTitle="Configuration">
<ProductConfigNavigation
environmentId={params.environmentId}
activeId="website-connection"
isMultiLanguageAllowed={isMultiLanguageAllowed}
productChannel={currentProductChannel}
/>
</PageHeader>
<div className="space-y-4">
<EnvironmentNotice environmentId={params.environmentId} subPageUrl="/product/website-connection" />
<SettingsCard
title="Website Connection Status"
description="Check if your website is successfully connected with Formbricks. Reload page to recheck.">
{environment && <WidgetStatusIndicator environment={environment} size="large" type="website" />}
</SettingsCard>
<SettingsCard
title="Your EnvironmentId"
description="This id uniquely identifies this Formbricks environment.">
<EnvironmentIdField environmentId={params.environmentId} />
</SettingsCard>
<SettingsCard
title="How to setup"
description="Follow these steps to setup the Formbricks widget within your website"
noPadding>
<SetupInstructions
environmentId={params.environmentId}
webAppUrl={WEBAPP_URL}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
type="website"
/>
</SettingsCard>
</div>
</PageContentWrapper>
);
};
export default Page;

View File

@@ -72,10 +72,18 @@ const Loading = () => {
current: pathname?.includes("/api-keys"),
},
{
id: "setup",
label: "Setup Guide",
id: "website-connection",
label: "Website Connection",
icon: <ListChecksIcon className="h-5 w-5" />,
current: pathname?.includes("/setup"),
current: pathname?.includes("/website-connection"),
hidden: true,
},
{
id: "app-connection",
label: "App Connection",
icon: <ListChecksIcon className="h-5 w-5" />,
current: pathname?.includes("/app-connection"),
hidden: true,
},
];

View File

@@ -6,6 +6,7 @@ import { getEnvironment } from "@formbricks/lib/environment/service";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
import { EnvironmentNotice } from "@formbricks/ui/EnvironmentNotice";
import { ErrorComponent } from "@formbricks/ui/ErrorComponent";
import { PageContentWrapper } from "@formbricks/ui/PageContentWrapper";
@@ -14,9 +15,12 @@ import { SettingsCard } from "../../settings/components/SettingsCard";
import { ApiKeyList } from "./components/ApiKeyList";
const Page = async ({ params }) => {
const environment = await getEnvironment(params.environmentId);
const organization = await getOrganizationByEnvironmentId(params.environmentId);
const session = await getServerSession(authOptions);
const [session, environment, product, organization] = await Promise.all([
getServerSession(authOptions),
getEnvironment(params.environmentId),
getProductByEnvironmentId(params.environmentId),
getOrganizationByEnvironmentId(params.environmentId),
]);
if (!environment) {
throw new Error("Environment not found");
@@ -31,6 +35,7 @@ const Page = async ({ params }) => {
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
const { isViewer } = getAccessFlags(currentUserMembership?.role);
const isMultiLanguageAllowed = await getMultiLanguagePermission(organization);
const currentProductChannel = product?.config.channel ?? null;
return !isViewer ? (
<PageContentWrapper>
@@ -39,6 +44,7 @@ const Page = async ({ params }) => {
environmentId={params.environmentId}
activeId="api-keys"
isMultiLanguageAllowed={isMultiLanguageAllowed}
productChannel={currentProductChannel}
/>
</PageHeader>
<EnvironmentNotice environmentId={environment.id} subPageUrl="/product/api-keys" />

View File

@@ -2,18 +2,21 @@
import { BrushIcon, KeyIcon, LanguagesIcon, ListChecksIcon, TagIcon, UsersIcon } from "lucide-react";
import { usePathname } from "next/navigation";
import { TProductConfigChannel } from "@formbricks/types/product";
import { SecondaryNavigation } from "@formbricks/ui/SecondaryNavigation";
interface ProductConfigNavigationProps {
environmentId: string;
activeId: string;
isMultiLanguageAllowed: boolean;
productChannel: TProductConfigChannel;
}
export const ProductConfigNavigation = ({
environmentId,
activeId,
isMultiLanguageAllowed,
productChannel,
}: ProductConfigNavigationProps) => {
const pathname = usePathname();
let navigation = [
@@ -54,11 +57,20 @@ export const ProductConfigNavigation = ({
current: pathname?.includes("/api-keys"),
},
{
id: "setup",
label: "Setup Guide",
id: "website-connection",
label: "Website Connection",
icon: <ListChecksIcon className="h-5 w-5" />,
href: `/environments/${environmentId}/product/setup`,
current: pathname?.includes("/setup"),
href: `/environments/${environmentId}/product/website-connection`,
current: pathname?.includes("/website-connection"),
hidden: !!(productChannel && productChannel !== "website"),
},
{
id: "app-connection",
label: "App Connection",
icon: <ListChecksIcon className="h-5 w-5" />,
href: `/environments/${environmentId}/product/app-connection`,
current: pathname?.includes("/app-connection"),
hidden: !!(productChannel && productChannel !== "app"),
},
];

View File

@@ -53,7 +53,7 @@ export const updateProductAction = async (
}
if (membership.role === "developer") {
if (!!data.name || !!data.brandColor || !!data.organizationId || !!data.environments) {
if (!!data.name || !!data.organizationId || !!data.environments) {
throw new AuthorizationError("Not authorized");
}
}
@@ -62,13 +62,13 @@ export const updateProductAction = async (
return updatedProduct;
};
export const deleteProductAction = async (environmentId: string, userId: string, productId: string) => {
export const deleteProductAction = async (environmentId: string, productId: string) => {
const session = await getServerSession(authOptions);
if (!session?.user) {
throw new AuthenticationError("Not authenticated");
}
const userId = session.user.id;
// get the environment from service and check if the user is allowed to update the product
let environment: TEnvironment | null = null;

View File

@@ -37,7 +37,6 @@ export const DeleteProduct = async ({ environmentId, product }: DeleteProductPro
isUserAdminOrOwner={isUserAdminOrOwner}
product={product}
environmentId={environmentId}
userId={session?.user.id ?? ""}
/>
);
};

View File

@@ -14,7 +14,6 @@ type DeleteProductRenderProps = {
isDeleteDisabled: boolean;
isUserAdminOrOwner: boolean;
product: TProduct;
userId: string;
};
export const DeleteProductRender = ({
@@ -22,7 +21,6 @@ export const DeleteProductRender = ({
isDeleteDisabled,
isUserAdminOrOwner,
product,
userId,
}: DeleteProductRenderProps) => {
const router = useRouter();
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
@@ -31,7 +29,7 @@ export const DeleteProductRender = ({
const handleDeleteProduct = async () => {
try {
setIsDeleting(true);
const deletedProduct = await deleteProductAction(environmentId, userId, product.id);
const deletedProduct = await deleteProductAction(environmentId, product.id);
if (!!deletedProduct?.id) {
toast.success("Product deleted successfully.");
router.push("/");

View File

@@ -81,10 +81,18 @@ const Loading = () => {
current: pathname?.includes("/api-keys"),
},
{
id: "setup",
label: "Setup Guide",
id: "website-connection",
label: "Website Connection",
icon: <ListChecksIcon className="h-5 w-5" />,
current: pathname?.includes("/setup"),
current: pathname?.includes("/website-connection"),
hidden: true,
},
{
id: "app-connection",
label: "App Connection",
icon: <ListChecksIcon className="h-5 w-5" />,
current: pathname?.includes("/app-connection"),
hidden: true,
},
];

View File

@@ -41,6 +41,7 @@ const Page = async ({ params }: { params: { environmentId: string } }) => {
}
const isMultiLanguageAllowed = await getMultiLanguagePermission(organization);
const currentProductChannel = product?.config.channel ?? null;
return (
<PageContentWrapper>
@@ -49,6 +50,7 @@ const Page = async ({ params }: { params: { environmentId: string } }) => {
environmentId={params.environmentId}
activeId="general"
isMultiLanguageAllowed={isMultiLanguageAllowed}
productChannel={currentProductChannel}
/>
</PageHeader>
@@ -59,11 +61,13 @@ const Page = async ({ params }: { params: { environmentId: string } }) => {
isProductNameEditDisabled={isProductNameEditDisabled}
/>
</SettingsCard>
<SettingsCard
title="Recontact Waiting Time"
description="Control how frequently users can be surveyed across all surveys.">
<EditWaitingTimeForm environmentId={params.environmentId} product={product} />
</SettingsCard>
{currentProductChannel !== "link" && (
<SettingsCard
title="Recontact Waiting Time"
description="Control how frequently users can be surveyed across all surveys.">
<EditWaitingTimeForm environmentId={params.environmentId} product={product} />
</SettingsCard>
)}
<SettingsCard
title="Delete Product"
description="Delete product with all surveys, responses, people, actions and attributes. This cannot be undone.">

View File

@@ -26,6 +26,7 @@ const Page = async ({ params }: { params: { environmentId: string } }) => {
if (!isMultiLanguageAllowed) {
notFound();
}
const currentProductChannel = product?.config.channel ?? null;
return (
<PageContentWrapper>
@@ -34,6 +35,7 @@ const Page = async ({ params }: { params: { environmentId: string } }) => {
environmentId={params.environmentId}
activeId="languages"
isMultiLanguageAllowed={isMultiLanguageAllowed}
productChannel={currentProductChannel}
/>
</PageHeader>
<SettingsCard

View File

@@ -119,6 +119,8 @@ export const ThemeStylingPreviewSurvey = ({
}
};
const currentProductChannel = product?.config.channel ?? null;
return (
<div className="flex h-full w-full flex-col items-center justify-items-center overflow-hidden">
<motion.div
@@ -210,11 +212,13 @@ export const ThemeStylingPreviewSurvey = ({
Link survey
</div>
<div
className={`${isAppSurvey ? "rounded-full bg-slate-200" : ""} cursor-pointer px-3 py-1 text-sm`}
onClick={() => setPreviewType("app")}>
App / Website survey
</div>
{currentProductChannel !== "link" && (
<div
className={`${isAppSurvey ? "rounded-full bg-slate-200" : ""} cursor-pointer px-3 py-1 text-sm`}
onClick={() => setPreviewType("app")}>
App / Website survey
</div>
)}
</div>
</div>
);

View File

@@ -56,10 +56,18 @@ const Loading = () => {
current: pathname?.includes("/api-keys"),
},
{
id: "setup",
label: "Setup Guide",
id: "website-connection",
label: "Website Connection",
icon: <ListChecksIcon className="h-5 w-5" />,
current: pathname?.includes("/setup"),
current: pathname?.includes("/website-connection"),
hidden: true,
},
{
id: "app-connection",
label: "App Connection",
icon: <ListChecksIcon className="h-5 w-5" />,
current: pathname?.includes("/app-connection"),
hidden: true,
},
];

View File

@@ -48,6 +48,7 @@ const Page = async ({ params }: { params: { environmentId: string } }) => {
}
const isMultiLanguageAllowed = await getMultiLanguagePermission(organization);
const currentProductChannel = product?.config.channel ?? null;
return (
<PageContentWrapper>
@@ -56,6 +57,7 @@ const Page = async ({ params }: { params: { environmentId: string } }) => {
environmentId={params.environmentId}
activeId="look"
isMultiLanguageAllowed={isMultiLanguageAllowed}
productChannel={currentProductChannel}
/>
</PageHeader>
<SettingsCard
@@ -72,27 +74,31 @@ const Page = async ({ params }: { params: { environmentId: string } }) => {
<SettingsCard title="Logo" description="Upload your company logo to brand surveys and link previews.">
<EditLogo product={product} environmentId={params.environmentId} isViewer={isViewer} />
</SettingsCard>
<SettingsCard
title="In-app Survey Placement"
description="Change where surveys will be shown in your web app.">
<EditPlacementForm product={product} environmentId={params.environmentId} />
</SettingsCard>
<SettingsCard
title="Formbricks Branding"
description="We love your support but understand if you toggle it off.">
<EditFormbricksBranding
type="linkSurvey"
product={product}
canRemoveBranding={canRemoveLinkBranding}
environmentId={params.environmentId}
/>
<EditFormbricksBranding
type="inAppSurvey"
product={product}
canRemoveBranding={canRemoveInAppBranding}
environmentId={params.environmentId}
/>
</SettingsCard>
{currentProductChannel !== "link" && (
<SettingsCard
title="In-app Survey Placement"
description="Change where surveys will be shown in your web app.">
<EditPlacementForm product={product} environmentId={params.environmentId} />
</SettingsCard>
)}
{currentProductChannel !== "link" && (
<SettingsCard
title="Formbricks Branding"
description="We love your support but understand if you toggle it off.">
<EditFormbricksBranding
type="linkSurvey"
product={product}
canRemoveBranding={canRemoveLinkBranding}
environmentId={params.environmentId}
/>
<EditFormbricksBranding
type="inAppSurvey"
product={product}
canRemoveBranding={canRemoveInAppBranding}
environmentId={params.environmentId}
/>
</SettingsCard>
)}
</PageContentWrapper>
);
};

View File

@@ -7,6 +7,7 @@ import { getEnvironment } from "@formbricks/lib/environment/service";
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
import { getTagsByEnvironmentId } from "@formbricks/lib/tag/service";
import { getTagsOnResponsesCount } from "@formbricks/lib/tagOnResponse/service";
import { ErrorComponent } from "@formbricks/ui/ErrorComponent";
@@ -19,10 +20,14 @@ const Page = async ({ params }) => {
if (!environment) {
throw new Error("Environment not found");
}
const tags = await getTagsByEnvironmentId(params.environmentId);
const environmentTagsCount = await getTagsOnResponsesCount(params.environmentId);
const organization = await getOrganizationByEnvironmentId(params.environmentId);
const session = await getServerSession(authOptions);
const [tags, environmentTagsCount, product, organization, session] = await Promise.all([
getTagsByEnvironmentId(params.environmentId),
getTagsOnResponsesCount(params.environmentId),
getProductByEnvironmentId(params.environmentId),
getOrganizationByEnvironmentId(params.environmentId),
getServerSession(authOptions),
]);
if (!environment) {
throw new Error("Environment not found");
@@ -40,6 +45,7 @@ const Page = async ({ params }) => {
const isTagSettingDisabled = isViewer;
const isMultiLanguageAllowed = await getMultiLanguagePermission(organization);
const currentProductChannel = product?.config.channel ?? null;
return !isTagSettingDisabled ? (
<PageContentWrapper>
@@ -48,6 +54,7 @@ const Page = async ({ params }) => {
environmentId={params.environmentId}
activeId="tags"
isMultiLanguageAllowed={isMultiLanguageAllowed}
productChannel={currentProductChannel}
/>
</PageHeader>
<SettingsCard title="Manage Tags" description="Add, merge and remove response tags.">

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