mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-30 02:10:12 -06:00
solve merge conflicts
This commit is contained in:
2
apps/demo-react-native/.env.example
Normal file
2
apps/demo-react-native/.env.example
Normal file
@@ -0,0 +1,2 @@
|
||||
EXPO_PUBLIC_API_HOST=http://192.168.178.20:3000
|
||||
EXPO_PUBLIC_FORMBRICKS_ENVIRONMENT_ID=clzr04nkd000bcdl110j0ijyq
|
||||
7
apps/demo-react-native/.eslintrc.js
Normal file
7
apps/demo-react-native/.eslintrc.js
Normal file
@@ -0,0 +1,7 @@
|
||||
module.exports = {
|
||||
extends: ["@formbricks/eslint-config/react.js"],
|
||||
parserOptions: {
|
||||
project: "tsconfig.json",
|
||||
tsconfigRootDir: __dirname,
|
||||
},
|
||||
};
|
||||
35
apps/demo-react-native/.gitignore
vendored
Normal file
35
apps/demo-react-native/.gitignore
vendored
Normal file
@@ -0,0 +1,35 @@
|
||||
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# Expo
|
||||
.expo/
|
||||
dist/
|
||||
web-build/
|
||||
|
||||
# Native
|
||||
*.orig.*
|
||||
*.jks
|
||||
*.p8
|
||||
*.p12
|
||||
*.key
|
||||
*.mobileprovision
|
||||
|
||||
# Metro
|
||||
.metro-health-check*
|
||||
|
||||
# debug
|
||||
npm-debug.*
|
||||
yarn-debug.*
|
||||
yarn-error.*
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
0
apps/demo-react-native/.npmrc
Normal file
0
apps/demo-react-native/.npmrc
Normal file
34
apps/demo-react-native/app.json
Normal file
34
apps/demo-react-native/app.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "react-native-demo",
|
||||
"slug": "react-native-demo",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "light",
|
||||
"splash": {
|
||||
"image": "./assets/splash.png",
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#ffffff"
|
||||
},
|
||||
"jsEngine": "hermes",
|
||||
"assetBundlePatterns": ["**/*"],
|
||||
"ios": {
|
||||
"supportsTablet": true,
|
||||
"infoPlist": {
|
||||
"NSCameraUsageDescription": "Take pictures for certain activities.",
|
||||
"NSPhotoLibraryUsageDescription": "Select pictures for certain activities.",
|
||||
"NSMicrophoneUsageDescription": "Need microphone access for recording videos."
|
||||
}
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/adaptive-icon.png",
|
||||
"backgroundColor": "#ffffff"
|
||||
}
|
||||
},
|
||||
"web": {
|
||||
"favicon": "./assets/favicon.png"
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
apps/demo-react-native/assets/adaptive-icon.png
Normal file
BIN
apps/demo-react-native/assets/adaptive-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
apps/demo-react-native/assets/favicon.png
Normal file
BIN
apps/demo-react-native/assets/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
BIN
apps/demo-react-native/assets/icon.png
Normal file
BIN
apps/demo-react-native/assets/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
BIN
apps/demo-react-native/assets/splash.png
Normal file
BIN
apps/demo-react-native/assets/splash.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
6
apps/demo-react-native/babel.config.js
Normal file
6
apps/demo-react-native/babel.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = function babel(api) {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: ["babel-preset-expo"],
|
||||
};
|
||||
};
|
||||
7
apps/demo-react-native/index.js
Normal file
7
apps/demo-react-native/index.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import { registerRootComponent } from "expo";
|
||||
import { LogBox } from "react-native";
|
||||
import App from "./src/app";
|
||||
|
||||
registerRootComponent(App);
|
||||
|
||||
LogBox.ignoreAllLogs();
|
||||
21
apps/demo-react-native/metro.config.js
Normal file
21
apps/demo-react-native/metro.config.js
Normal file
@@ -0,0 +1,21 @@
|
||||
// Learn more https://docs.expo.io/guides/customizing-metro
|
||||
const path = require("node:path");
|
||||
const { getDefaultConfig } = require("expo/metro-config");
|
||||
|
||||
// Find the workspace root, this can be replaced with `find-yarn-workspace-root`
|
||||
const workspaceRoot = path.resolve(__dirname, "../..");
|
||||
const projectRoot = __dirname;
|
||||
|
||||
const config = getDefaultConfig(projectRoot);
|
||||
|
||||
// 1. Watch all files within the monorepo
|
||||
config.watchFolders = [workspaceRoot];
|
||||
// 2. Let Metro know where to resolve packages, and in what order
|
||||
config.resolver.nodeModulesPaths = [
|
||||
path.resolve(projectRoot, "node_modules"),
|
||||
path.resolve(workspaceRoot, "node_modules"),
|
||||
];
|
||||
// 3. Force Metro to resolve (sub)dependencies only from the `nodeModulesPaths`
|
||||
config.resolver.disableHierarchicalLookup = true;
|
||||
|
||||
module.exports = config;
|
||||
28
apps/demo-react-native/package.json
Normal file
28
apps/demo-react-native/package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "@formbricks/demo-react-native",
|
||||
"version": "1.0.0",
|
||||
"main": "./index.js",
|
||||
"scripts": {
|
||||
"dev": "expo start",
|
||||
"android": "expo start --android",
|
||||
"ios": "expo start --ios",
|
||||
"web": "expo start --web",
|
||||
"eject": "expo eject",
|
||||
"clean": "rimraf .turbo node_modules .expo"
|
||||
},
|
||||
"dependencies": {
|
||||
"@formbricks/js": "workspace:*",
|
||||
"@formbricks/react-native": "workspace:*",
|
||||
"expo": "^51.0.26",
|
||||
"expo-status-bar": "~1.12.1",
|
||||
"react": "^18.2.0",
|
||||
"react-native": "^0.74.4",
|
||||
"react-native-webview": "13.8.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.25.2",
|
||||
"@types/react": "~18.2.79",
|
||||
"typescript": "^5.3.3"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
52
apps/demo-react-native/src/app.tsx
Normal file
52
apps/demo-react-native/src/app.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { StatusBar } from "expo-status-bar";
|
||||
import { Button, LogBox, StyleSheet, Text, View } from "react-native";
|
||||
import Formbricks, { track } from "@formbricks/react-native";
|
||||
|
||||
LogBox.ignoreAllLogs();
|
||||
|
||||
export default function App(): JSX.Element {
|
||||
if (!process.env.EXPO_PUBLIC_FORMBRICKS_ENVIRONMENT_ID) {
|
||||
throw new Error("EXPO_PUBLIC_FORMBRICKS_ENVIRONMENT_ID is required");
|
||||
}
|
||||
|
||||
if (!process.env.EXPO_PUBLIC_API_HOST) {
|
||||
throw new Error("EXPO_PUBLIC_API_HOST is required");
|
||||
}
|
||||
|
||||
const config = {
|
||||
environmentId: process.env.EXPO_PUBLIC_FORMBRICKS_ENVIRONMENT_ID,
|
||||
apiHost: process.env.EXPO_PUBLIC_API_HOST,
|
||||
userId: "random user id",
|
||||
attributes: {
|
||||
language: "en",
|
||||
testAttr: "attr-test",
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text>Formbricks React Native SDK Demo</Text>
|
||||
|
||||
<Button
|
||||
title="Trigger Code Action"
|
||||
onPress={() => {
|
||||
track("code").catch((error: unknown) => {
|
||||
// eslint-disable-next-line no-console -- logging is allowed in demo apps
|
||||
console.error("Error tracking event:", error);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<StatusBar style="auto" />
|
||||
<Formbricks initConfig={config} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: "#fff",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
});
|
||||
6
apps/demo-react-native/tsconfig.json
Normal file
6
apps/demo-react-native/tsconfig.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true
|
||||
}
|
||||
}
|
||||
@@ -128,6 +128,7 @@ const AppPage = ({}) => {
|
||||
}}>
|
||||
Reset
|
||||
</button>
|
||||
|
||||
<p className="text-xs text-slate-700 dark:text-slate-300">
|
||||
If you made a change in Formbricks app and it does not seem to work, hit 'Reset' and
|
||||
try again.
|
||||
|
||||
@@ -31,12 +31,18 @@ const libraries = [
|
||||
description: "Simply add us to your router change and sit back!",
|
||||
logo: logoVueJs,
|
||||
},
|
||||
{
|
||||
href: "#react-native",
|
||||
name: "React Native",
|
||||
description: "Easily integrate our SDK with your React Native app for seamless survey support!",
|
||||
logo: logoReactJs,
|
||||
},
|
||||
];
|
||||
|
||||
export const Libraries = () => {
|
||||
return (
|
||||
<div className="my-16 xl:max-w-none">
|
||||
<div className="not-prose mt-4 grid grid-cols-1 gap-x-6 gap-y-10 border-slate-900/5 sm:grid-cols-2 xl:max-w-none xl:grid-cols-3 dark:border-white/5">
|
||||
<div className="not-prose mt-4 grid grid-cols-1 gap-x-6 gap-y-10 border-slate-900/5 xl:max-w-none xl:grid-cols-2 2xl:grid-cols-3 dark:border-white/5">
|
||||
{libraries.map((library) => (
|
||||
<a
|
||||
key={library.name}
|
||||
|
||||
@@ -346,6 +346,66 @@ router.afterEach((to, from) => {
|
||||
|
||||
Refer to our [Example VueJs project](https://github.com/formbricks/examples/tree/main/vuejs) for more help! Now visit the [Validate your Setup](#validate-your-setup) section to verify your setup!
|
||||
|
||||
## React Native
|
||||
|
||||
Install the Formbricks React Native SDK using one of the package managers, i.e., npm, pnpm, or yarn.
|
||||
|
||||
<Col>
|
||||
<CodeGroup title="Install Formbricks JS library">
|
||||
```shell {{ title: 'npm' }}
|
||||
npm install @formbricks/react-native
|
||||
```
|
||||
```shell {{ title: 'pnpm' }}
|
||||
pnpm add @formbricks/react-native
|
||||
```
|
||||
```shell {{ title: 'yarn' }}
|
||||
yarn add @formbricks/react-native
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
</Col>
|
||||
Now, update your App.js/App.tsx file to initialize Formbricks:
|
||||
<Col>
|
||||
<CodeGroup title="src/App.js">
|
||||
|
||||
```js
|
||||
// other imports
|
||||
import Formbricks from "@formbricks/react-native";
|
||||
|
||||
const config = {
|
||||
environmentId: "<environment-id>",
|
||||
apiHost: "<api-host>",
|
||||
userId: "<user-id>",
|
||||
};
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<>
|
||||
{/* Your app content */}
|
||||
<Formbricks initConfig={config} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
</Col>
|
||||
### Required customizations to be made
|
||||
|
||||
<Properties>
|
||||
<Property name="environment-id" type="string">
|
||||
Formbricks Environment ID.
|
||||
</Property>
|
||||
<Property name="api-host" type="string">
|
||||
URL of the hosted Formbricks instance.
|
||||
</Property>
|
||||
<Property name="userId" type="string">
|
||||
User ID of the user who has active session.
|
||||
</Property>
|
||||
</Properties>
|
||||
|
||||
---
|
||||
|
||||
## Validate your setup
|
||||
|
||||
Once you have completed the steps above, you can validate your setup by checking the **Setup Checklist** in the Settings. Your widget status indicator should go from this:
|
||||
|
||||
@@ -19,10 +19,12 @@ export const metadata = {
|
||||
|
||||
# Quickstart
|
||||
|
||||
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. Let’s go!
|
||||
App surveys have 6-10x better conversion rates than emailed surveys. This tutorial explains how to run a survey in both your web app and mobile app (React Native) in just 10 to 15 minutes. Let’s 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:
|
||||
@@ -35,7 +37,7 @@ App surveys have 6-10x better conversion rates than emailed out surveys. This tu
|
||||
src={I1}
|
||||
alt="Choose website survey from survey type"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl "
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
2. **Connect your App/Website**: Once you get through a couple of onboarding steps, you’ll be asked to connect your app or website. This is where you’ll find the code snippet for both HTML as well as the npm package which you need to embed in your app:
|
||||
|
||||
127
apps/docs/app/developer-docs/app-survey-rn-sdk/page.mdx
Normal file
127
apps/docs/app/developer-docs/app-survey-rn-sdk/page.mdx
Normal file
@@ -0,0 +1,127 @@
|
||||
import { MdxImage } from "@/components/MdxImage";
|
||||
|
||||
export const metadata = {
|
||||
title: "Formbricks App Survey SDK",
|
||||
description:
|
||||
"An overview of all available methods & how to integrate Formbricks App Surveys for frontend developers in web applications. Learn the key methods, configuration settings, and best practices.",
|
||||
};
|
||||
|
||||
#### Developer Docs
|
||||
|
||||
# React Native SDK: App Survey
|
||||
|
||||
### Overview
|
||||
|
||||
The Formbricks React Native SDK can be used for seamlessly integrating App Surveys into your React Native Apps. In this section, we'll explore how to leverage the SDK for **app** surveys. It’s available on npm [here](https://www.npmjs.com/package/@formbricks/js/).
|
||||
|
||||
### Install
|
||||
|
||||
<Col>
|
||||
<CodeGroup title="npm">
|
||||
|
||||
```js {{ title: 'npm' }}
|
||||
npm install @formbricks/react-native
|
||||
```
|
||||
|
||||
```js {{ title: 'yarn' }}
|
||||
yarn add @formbricks/react-native
|
||||
```
|
||||
|
||||
```js {{ title: 'pnpm' }}
|
||||
pnpm add @formbricks/react-native
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
</Col>
|
||||
|
||||
## Methods
|
||||
|
||||
### Initialize Formbricks
|
||||
|
||||
In your React Native app, initialize the Formbricks React Native Client for app surveys where you pass the userId (creates a user if not existing in Formbricks) to attribute & target the user based on their actions.
|
||||
|
||||
<Col>
|
||||
<CodeGroup title="Initialize Formbricks">
|
||||
|
||||
```javascript
|
||||
// other imports
|
||||
import Formbricks from "@formbricks/react-native";
|
||||
|
||||
const config = {
|
||||
environmentId: "<environment-id>",
|
||||
apiHost: "<api-host>",
|
||||
userId: "<user-id>",
|
||||
};
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<>
|
||||
{/* Your app content */}
|
||||
<Formbricks initConfig={config} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
</Col>
|
||||
|
||||
The moment you initialise Formbricks, your user will start seeing surveys that get triggered on simpler actions such as on New Session.
|
||||
|
||||
### Set Attribute
|
||||
|
||||
You can set custom attributes for the identified user. This can be helpful for segmenting users based on specific characteristics or properties. To learn how to set custom user attributes, please check out our [User Attributes Guide](/app-surveys/user-identification).
|
||||
|
||||
<Col>
|
||||
<CodeGroup>
|
||||
|
||||
```js
|
||||
formbricks.setAttribute("Plan", "Paid");
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
</Col>
|
||||
|
||||
### Track Action
|
||||
|
||||
Track user actions to trigger surveys based on user interactions, such as button clicks or scrolling:
|
||||
|
||||
<Col>
|
||||
<CodeGroup>
|
||||
|
||||
```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.
|
||||
|
||||
<Col>
|
||||
<CodeGroup>
|
||||
|
||||
```js
|
||||
formbricks.logout();
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
</Col>
|
||||
|
||||
After calling formbricks.logout(), you'll need to reinitialize Formbricks before using any of its features again. Ensure that you properly reinitialize Formbricks to avoid unexpected errors or behavior in your application.
|
||||
|
||||
### Reset
|
||||
|
||||
Reset the current instance and fetch the latest surveys and state again:
|
||||
|
||||
<Col>
|
||||
<CodeGroup>
|
||||
|
||||
```js
|
||||
formbricks.reset();
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
</Col>
|
||||
@@ -106,7 +106,7 @@ sed -i '/^CRON_SECRET=/c\CRON_SECRET='$(openssl rand -hex 32) .env
|
||||
</CodeGroup>
|
||||
</Col>
|
||||
|
||||
- For Mac
|
||||
- For Mac
|
||||
<Col>
|
||||
<CodeGroup title="For Mac">
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ We currently have the following Management API methods exposed and below is thei
|
||||
- [Me API](https://documenter.getpostman.com/view/11026000/2sA3Bq5XEh#79e08365-641d-4b2d-aea2-9a855e0438ec) - Retrieve Account Information
|
||||
- [People API](https://documenter.getpostman.com/view/11026000/2sA3Bq5XEh#cffc27a6-dafb-428f-8ea7-5165bedb911e) - List and Delete People
|
||||
- [Response API](https://documenter.getpostman.com/view/11026000/2sA3Bq5XEh#e544ec0d-8b30-4e33-8d35-2441cb40d676) - List, List by Survey, Update, and Delete Responses
|
||||
- [Survey API](https://documenter.getpostman.com/view/11026000/2sA3Bq5XEh#953189b2-37b5-4429-a7bd-f4d01ceae242) - List, Create, Update, and Delete Surveys
|
||||
- [Survey API](https://documenter.getpostman.com/view/11026000/2sA3Bq5XEh#953189b2-37b5-4429-a7bd-f4d01ceae242) - List, Create, Update, generate multiple suId and Delete Surveys
|
||||
- [Webhook API](https://documenter.getpostman.com/view/11026000/2sA3Bq5XEh#62e6ec65-021b-42a4-ac93-d1434b393c6c) - List, Create, and Delete Webhooks
|
||||
|
||||
## How to Generate an API key
|
||||
|
||||
@@ -139,8 +139,9 @@ export const navigation: Array<NavGroup> = [
|
||||
{ title: "Zapier", href: "/developer-docs/integrations/zapier" },
|
||||
],
|
||||
},
|
||||
{ title: "SDK: App Survey", href: "/developer-docs/app-survey-sdk" },
|
||||
{ title: "SDK: Website Survey", href: "/developer-docs/website-survey-sdk" },
|
||||
{ title: "JS SDK: App Survey", href: "/developer-docs/app-survey-sdk" },
|
||||
{ title: "RN SDK: App Survey", href: "/developer-docs/app-survey-rn-sdk" },
|
||||
{ title: "JS SDK: Website Survey", href: "/developer-docs/website-survey-sdk" },
|
||||
{ title: "SDK: Formbricks API", href: "/developer-docs/api-sdk" },
|
||||
{ title: "REST API", href: "/developer-docs/rest-api" },
|
||||
{ title: "Webhooks", href: "/developer-docs/webhooks" },
|
||||
|
||||
@@ -6,7 +6,7 @@ import { authenticatedActionClient } from "@formbricks/lib/actionClient";
|
||||
import { checkAuthorization } from "@formbricks/lib/actionClient/utils";
|
||||
import { INVITE_DISABLED } from "@formbricks/lib/constants";
|
||||
import { inviteUser } from "@formbricks/lib/invite/service";
|
||||
import { ZId } from "@formbricks/types/environment";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { AuthenticationError } from "@formbricks/types/errors";
|
||||
import { ZMembershipRole } from "@formbricks/types/memberships";
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
import { surveyCache } from "@formbricks/lib/survey/cache";
|
||||
import { loadNewSegmentInSurvey, updateSurvey } from "@formbricks/lib/survey/service";
|
||||
import { ZActionClassInput } from "@formbricks/types/action-classes";
|
||||
import { ZId } from "@formbricks/types/environment";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { ZBaseFilters, ZSegmentFilters, ZSegmentUpdateInput } from "@formbricks/types/segment";
|
||||
import { ZSurvey } from "@formbricks/types/surveys/types";
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ interface ColorSurveyBgProps {
|
||||
}
|
||||
|
||||
export const ColorSurveyBg = ({ handleBgChange, colors, background }: ColorSurveyBgProps) => {
|
||||
const [color, setColor] = useState(background || "#ffff");
|
||||
const [color, setColor] = useState(background || "#FFFFFF");
|
||||
|
||||
const handleBg = (x: string) => {
|
||||
setColor(x);
|
||||
@@ -23,7 +23,7 @@ export const ColorSurveyBg = ({ handleBgChange, colors, background }: ColorSurve
|
||||
{colors.map((x) => {
|
||||
return (
|
||||
<div
|
||||
className={`h-16 w-16 cursor-pointer rounded-lg ${
|
||||
className={`h-16 w-16 cursor-pointer rounded-lg border border-slate-300 ${
|
||||
color === x ? "border-4 border-slate-500" : ""
|
||||
}`}
|
||||
key={x}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
import { GripIcon } from "lucide-react";
|
||||
import { GripIcon, Handshake, Undo2 } from "lucide-react";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { recallToHeadline } from "@formbricks/lib/utils/recall";
|
||||
import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
@@ -129,7 +129,13 @@ export const EditEndingCard = ({
|
||||
"flex w-10 flex-col items-center justify-between rounded-l-lg border-b border-l border-t py-2 group-aria-expanded:rounded-bl-none",
|
||||
isInvalid ? "bg-red-400" : "bg-white group-hover:bg-slate-50"
|
||||
)}>
|
||||
<p className="mt-3">{endingCard.type === "endScreen" ? "🙏" : "↪️"}</p>
|
||||
<div className="mt-3 flex w-full justify-center">
|
||||
{endingCard.type === "endScreen" ? (
|
||||
<Handshake className="h-4 w-4" />
|
||||
) : (
|
||||
<Undo2 className="h-4 w-4 rotate-180" />
|
||||
)}
|
||||
</div>
|
||||
<button className="opacity-0 transition-all duration-300 hover:cursor-move group-hover:opacity-100">
|
||||
<GripIcon className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
import { Hand } from "lucide-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { LocalizedEditor } from "@formbricks/ee/multi-language/components/localized-editor";
|
||||
@@ -66,7 +67,7 @@ export const EditWelcomeCard = ({
|
||||
"flex w-10 items-center justify-center rounded-l-lg border-b border-l border-t group-aria-expanded:rounded-bl-none",
|
||||
isInvalid ? "bg-red-400" : "bg-white group-hover:bg-slate-50"
|
||||
)}>
|
||||
<p>✋</p>
|
||||
<Hand className="h-4 w-4" />
|
||||
</div>
|
||||
<Collapsible.Root
|
||||
open={open}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
import { EyeOff } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { extractRecallInfo } from "@formbricks/lib/utils/recall";
|
||||
import { TSurvey, TSurveyHiddenFields } from "@formbricks/types/surveys/types";
|
||||
import { validateId } from "@formbricks/types/surveys/validation";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
@@ -36,9 +38,26 @@ export const HiddenFieldsCard = ({
|
||||
}
|
||||
};
|
||||
|
||||
const updateSurvey = (data: TSurveyHiddenFields) => {
|
||||
const updateSurvey = (data: TSurveyHiddenFields, currentFieldId?: string) => {
|
||||
const questions = [...localSurvey.questions];
|
||||
|
||||
// Remove recall info from question headlines
|
||||
if (currentFieldId) {
|
||||
questions.forEach((question) => {
|
||||
for (const [languageCode, headline] of Object.entries(question.headline)) {
|
||||
if (headline.includes(`recall:${currentFieldId}`)) {
|
||||
const recallInfo = extractRecallInfo(headline);
|
||||
if (recallInfo) {
|
||||
question.headline[languageCode] = headline.replace(recallInfo, "");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setLocalSurvey({
|
||||
...localSurvey,
|
||||
questions,
|
||||
hiddenFields: {
|
||||
...localSurvey.hiddenFields,
|
||||
...data,
|
||||
@@ -53,7 +72,7 @@ export const HiddenFieldsCard = ({
|
||||
open ? "bg-slate-50" : "bg-white group-hover:bg-slate-50",
|
||||
"flex w-10 items-center justify-center rounded-l-lg border-b border-l border-t group-aria-expanded:rounded-bl-none"
|
||||
)}>
|
||||
<p>🥷</p>
|
||||
<EyeOff className="h-4 w-4" />
|
||||
</div>
|
||||
<Collapsible.Root
|
||||
open={open}
|
||||
@@ -93,10 +112,13 @@ export const HiddenFieldsCard = ({
|
||||
<Tag
|
||||
key={fieldId}
|
||||
onDelete={() => {
|
||||
updateSurvey({
|
||||
enabled: true,
|
||||
fieldIds: localSurvey.hiddenFields?.fieldIds?.filter((q) => q !== fieldId),
|
||||
});
|
||||
updateSurvey(
|
||||
{
|
||||
enabled: true,
|
||||
fieldIds: localSurvey.hiddenFields?.fieldIds?.filter((q) => q !== fieldId),
|
||||
},
|
||||
fieldId
|
||||
);
|
||||
}}
|
||||
tagId={fieldId}
|
||||
tagName={fieldId}
|
||||
|
||||
@@ -18,7 +18,7 @@ const tabs: Tab[] = [
|
||||
{
|
||||
id: "styling",
|
||||
label: "Styling",
|
||||
icon: <PaintbrushIcon />,
|
||||
icon: <PaintbrushIcon className="h-5 w-5" />,
|
||||
},
|
||||
{
|
||||
id: "settings",
|
||||
@@ -46,7 +46,7 @@ export const QuestionsAudienceTabs = ({
|
||||
}, [isStylingTabVisible]);
|
||||
|
||||
return (
|
||||
<div className="fixed z-30 flex h-14 w-full items-center justify-center border bg-white md:w-1/2">
|
||||
<div className="fixed z-30 flex h-12 w-full items-center justify-center border-b bg-white md:w-1/2">
|
||||
<nav className="flex h-full items-center space-x-4" aria-label="Tabs">
|
||||
{tabsComputed.map((tab) => (
|
||||
<button
|
||||
@@ -55,9 +55,9 @@ export const QuestionsAudienceTabs = ({
|
||||
onClick={() => setActiveId(tab.id)}
|
||||
className={cn(
|
||||
tab.id === activeId
|
||||
? "border-brand-dark border-b-2 font-semibold text-slate-900"
|
||||
: "text-slate-500 hover:text-slate-700",
|
||||
"flex h-full items-center px-3 text-sm font-medium"
|
||||
? "border-brand-dark font-semibold text-slate-900"
|
||||
: "border-transparent text-slate-500 hover:text-slate-700",
|
||||
"flex h-full items-center border-b-2 px-3 text-sm font-medium"
|
||||
)}
|
||||
aria-current={tab.id === activeId ? "page" : undefined}>
|
||||
{tab.icon && <div className="mr-2 h-5 w-5">{tab.icon}</div>}
|
||||
|
||||
@@ -14,7 +14,7 @@ import { createId } from "@paralleldrive/cuid2";
|
||||
import React, { SetStateAction, useEffect, useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { MultiLanguageCard } from "@formbricks/ee/multi-language/components/multi-language-card";
|
||||
import { addMultiLanguageLabels, extractLanguageCodes, getLocalizedValue } from "@formbricks/lib/i18n/utils";
|
||||
import { addMultiLanguageLabels, extractLanguageCodes } from "@formbricks/lib/i18n/utils";
|
||||
import { structuredClone } from "@formbricks/lib/pollyfills/structuredClone";
|
||||
import { getDefaultEndingCard } from "@formbricks/lib/templates";
|
||||
import { checkForEmptyFallBackValue, extractRecallInfo } from "@formbricks/lib/utils/recall";
|
||||
@@ -209,15 +209,14 @@ export const QuestionsView = ({
|
||||
const activeQuestionIdTemp = activeQuestionId ?? localSurvey.questions[0].id;
|
||||
let updatedSurvey: TSurvey = { ...localSurvey };
|
||||
|
||||
// check if we are recalling from this question
|
||||
// check if we are recalling from this question for every language
|
||||
updatedSurvey.questions.forEach((question) => {
|
||||
if (question.headline[selectedLanguageCode].includes(`recall:${questionId}`)) {
|
||||
const recallInfo = extractRecallInfo(getLocalizedValue(question.headline, selectedLanguageCode));
|
||||
if (recallInfo) {
|
||||
question.headline[selectedLanguageCode] = question.headline[selectedLanguageCode].replace(
|
||||
recallInfo,
|
||||
""
|
||||
);
|
||||
for (const [languageCode, headline] of Object.entries(question.headline)) {
|
||||
if (headline.includes(`recall:${questionId}`)) {
|
||||
const recallInfo = extractRecallInfo(headline);
|
||||
if (recallInfo) {
|
||||
question.headline[languageCode] = headline.replace(recallInfo, "");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -359,7 +358,7 @@ export const QuestionsView = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-16 w-full px-5 py-4">
|
||||
<div className="mt-12 w-full px-5 py-4">
|
||||
<div className="mb-5 flex w-full flex-col gap-5">
|
||||
<EditWelcomeCard
|
||||
localSurvey={localSurvey}
|
||||
@@ -434,6 +433,13 @@ export const QuestionsView = ({
|
||||
activeQuestionId={activeQuestionId}
|
||||
/>
|
||||
|
||||
{/* <SurveyVariablesCard
|
||||
localSurvey={localSurvey}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
activeQuestionId={activeQuestionId}
|
||||
setActiveQuestionId={setActiveQuestionId}
|
||||
/> */}
|
||||
|
||||
<MultiLanguageCard
|
||||
localSurvey={localSurvey}
|
||||
product={product}
|
||||
|
||||
@@ -206,7 +206,7 @@ export const SurveyEditor = ({
|
||||
)}
|
||||
</main>
|
||||
|
||||
<aside className="group hidden flex-1 flex-shrink-0 items-center justify-center overflow-hidden border-l border-slate-200 bg-slate-100 py-6 shadow-inner md:flex md:flex-col">
|
||||
<aside className="group hidden flex-1 flex-shrink-0 items-center justify-center overflow-hidden border-l border-slate-200 bg-slate-100 pb-2 pt-4 shadow-inner md:flex md:flex-col">
|
||||
<PreviewSurvey
|
||||
survey={localSurvey}
|
||||
questionId={activeQuestionId}
|
||||
|
||||
@@ -286,10 +286,12 @@ export const SurveyMenuBar = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="border-b border-slate-200 bg-white px-5 py-3 sm:flex sm:items-center sm:justify-between">
|
||||
<div className="flex items-center space-x-2 whitespace-nowrap">
|
||||
<div className="border-b border-slate-200 bg-white px-5 py-2.5 sm:flex sm:items-center sm:justify-between">
|
||||
<div className="flex h-full items-center space-x-2 whitespace-nowrap">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="h-full"
|
||||
StartIcon={ArrowLeftIcon}
|
||||
onClick={() => {
|
||||
handleBack();
|
||||
@@ -303,11 +305,11 @@ export const SurveyMenuBar = ({
|
||||
const updatedSurvey = { ...localSurvey, name: e.target.value };
|
||||
setLocalSurvey(updatedSurvey);
|
||||
}}
|
||||
className="w-72 border-white hover:border-slate-200"
|
||||
className="h-8 w-72 border-white py-0 hover:border-slate-200"
|
||||
/>
|
||||
</div>
|
||||
{responseCount > 0 && (
|
||||
<div className="ju flex items-center rounded-lg border border-amber-200 bg-amber-100 p-2 text-amber-700 shadow-sm lg:mx-auto">
|
||||
<div className="ju flex items-center rounded-lg border border-amber-200 bg-amber-100 p-1.5 text-amber-800 shadow-sm lg:mx-auto">
|
||||
<TooltipProvider delayDuration={50}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
@@ -318,7 +320,9 @@ export const SurveyMenuBar = ({
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<p className="hidden pl-1 text-xs md:text-sm lg:block">{cautionText}</p>
|
||||
<p className="hidden text-ellipsis whitespace-nowrap pl-1.5 text-xs md:text-sm lg:block">
|
||||
{cautionText}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-3 flex sm:ml-4 sm:mt-0">
|
||||
@@ -332,6 +336,7 @@ export const SurveyMenuBar = ({
|
||||
<Button
|
||||
disabled={disableSave}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="mr-3"
|
||||
loading={isSurveySaving}
|
||||
onClick={() => handleSurveySave()}
|
||||
@@ -342,6 +347,7 @@ export const SurveyMenuBar = ({
|
||||
<Button
|
||||
disabled={disableSave}
|
||||
className="mr-3"
|
||||
size="sm"
|
||||
loading={isSurveySaving}
|
||||
onClick={() => handleSaveAndGoBack()}>
|
||||
Save & Close
|
||||
@@ -349,6 +355,7 @@ export const SurveyMenuBar = ({
|
||||
)}
|
||||
{localSurvey.status === "draft" && audiencePrompt && !isLinkSurvey && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setAudiencePrompt(false);
|
||||
setActiveId("settings");
|
||||
@@ -360,6 +367,7 @@ export const SurveyMenuBar = ({
|
||||
{/* Always display Publish button for link surveys for better CR */}
|
||||
{localSurvey.status === "draft" && (!audiencePrompt || isLinkSurvey) && (
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={isSurveySaving || containsEmptyTriggers}
|
||||
loading={isSurveyPublishing}
|
||||
onClick={handleSurveyPublish}>
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
"use client";
|
||||
|
||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { SurveyVariablesCardItem } from "./SurveyVariablesCardItem";
|
||||
|
||||
interface SurveyVariablesCardProps {
|
||||
localSurvey: TSurvey;
|
||||
setLocalSurvey: (survey: TSurvey) => void;
|
||||
activeQuestionId: string | null;
|
||||
setActiveQuestionId: (id: string | null) => void;
|
||||
}
|
||||
|
||||
const variablesCardId = `fb-variables-${Date.now()}`;
|
||||
|
||||
export const SurveyVariablesCard = ({
|
||||
localSurvey,
|
||||
setLocalSurvey,
|
||||
activeQuestionId,
|
||||
setActiveQuestionId,
|
||||
}: SurveyVariablesCardProps) => {
|
||||
const open = activeQuestionId === variablesCardId;
|
||||
|
||||
const setOpenState = (state: boolean) => {
|
||||
if (state) {
|
||||
setActiveQuestionId(variablesCardId);
|
||||
} else {
|
||||
setActiveQuestionId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn(open ? "shadow-lg" : "shadow-md", "group z-10 flex flex-row rounded-lg bg-white")}>
|
||||
<div
|
||||
className={cn(
|
||||
open ? "bg-slate-50" : "bg-white group-hover:bg-slate-50",
|
||||
"flex w-10 items-center justify-center rounded-l-lg border-b border-l border-t group-aria-expanded:rounded-bl-none"
|
||||
)}>
|
||||
<p>🪣</p>
|
||||
</div>
|
||||
<Collapsible.Root
|
||||
open={open}
|
||||
onOpenChange={setOpenState}
|
||||
className="flex-1 rounded-r-lg border border-slate-200 transition-all duration-300 ease-in-out">
|
||||
<Collapsible.CollapsibleTrigger
|
||||
asChild
|
||||
className="flex cursor-pointer justify-between p-4 hover:bg-slate-50">
|
||||
<div>
|
||||
<div className="inline-flex">
|
||||
<div>
|
||||
<p className="text-sm font-semibold">Variables</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Collapsible.CollapsibleTrigger>
|
||||
<Collapsible.CollapsibleContent className="px-4 pb-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
{localSurvey.variables.length > 0 ? (
|
||||
localSurvey.variables.map((variable) => (
|
||||
<SurveyVariablesCardItem
|
||||
key={variable.id}
|
||||
mode="edit"
|
||||
variable={variable}
|
||||
localSurvey={localSurvey}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<p className="mt-2 text-sm italic text-slate-500">No variables yet. Add the first one below.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SurveyVariablesCardItem mode="create" localSurvey={localSurvey} setLocalSurvey={setLocalSurvey} />
|
||||
</Collapsible.CollapsibleContent>
|
||||
</Collapsible.Root>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,224 @@
|
||||
"use client";
|
||||
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { TrashIcon } from "lucide-react";
|
||||
import React, { useCallback, useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { extractRecallInfo } from "@formbricks/lib/utils/recall";
|
||||
import { TSurvey, TSurveyVariable } from "@formbricks/types/surveys/types";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { FormControl, FormField, FormItem, FormProvider } from "@formbricks/ui/Form";
|
||||
import { Input } from "@formbricks/ui/Input";
|
||||
import { Label } from "@formbricks/ui/Label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@formbricks/ui/Select";
|
||||
|
||||
interface SurveyVariablesCardItemProps {
|
||||
variable?: TSurveyVariable;
|
||||
localSurvey: TSurvey;
|
||||
setLocalSurvey: React.Dispatch<React.SetStateAction<TSurvey>>;
|
||||
mode: "create" | "edit";
|
||||
}
|
||||
|
||||
export const SurveyVariablesCardItem = ({
|
||||
variable,
|
||||
localSurvey,
|
||||
setLocalSurvey,
|
||||
mode,
|
||||
}: SurveyVariablesCardItemProps) => {
|
||||
const form = useForm<TSurveyVariable>({
|
||||
defaultValues: variable ?? {
|
||||
id: createId(),
|
||||
name: "",
|
||||
type: "number",
|
||||
value: 0,
|
||||
},
|
||||
mode: "onChange",
|
||||
});
|
||||
|
||||
const { errors } = form.formState;
|
||||
const isNameError = !!errors.name?.message;
|
||||
const variableType = form.watch("type");
|
||||
|
||||
const editSurveyVariable = useCallback(
|
||||
(data: TSurveyVariable) => {
|
||||
setLocalSurvey((prevSurvey) => {
|
||||
const updatedVariables = prevSurvey.variables.map((v) => (v.id === data.id ? data : v));
|
||||
return { ...prevSurvey, variables: updatedVariables };
|
||||
});
|
||||
},
|
||||
[setLocalSurvey]
|
||||
);
|
||||
|
||||
const createSurveyVariable = (data: TSurveyVariable) => {
|
||||
setLocalSurvey({
|
||||
...localSurvey,
|
||||
variables: [...localSurvey.variables, data],
|
||||
});
|
||||
|
||||
form.reset({
|
||||
id: createId(),
|
||||
name: "",
|
||||
type: "number",
|
||||
value: 0,
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (mode === "create") {
|
||||
return;
|
||||
}
|
||||
|
||||
const subscription = form.watch(() => form.handleSubmit(editSurveyVariable)());
|
||||
return () => subscription.unsubscribe();
|
||||
}, [form, mode, editSurveyVariable]);
|
||||
|
||||
const onVaribleDelete = (variable: TSurveyVariable) => {
|
||||
const questions = [...localSurvey.questions];
|
||||
|
||||
// find if this variable is used in any question's recall and remove it for every language
|
||||
|
||||
questions.forEach((question) => {
|
||||
for (const [languageCode, headline] of Object.entries(question.headline)) {
|
||||
if (headline.includes(`recall:${variable.id}`)) {
|
||||
const recallInfo = extractRecallInfo(headline);
|
||||
if (recallInfo) {
|
||||
question.headline[languageCode] = headline.replace(recallInfo, "");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
setLocalSurvey((prevSurvey) => {
|
||||
const updatedVariables = prevSurvey.variables.filter((v) => v.id !== variable.id);
|
||||
return { ...prevSurvey, variables: updatedVariables, questions };
|
||||
});
|
||||
};
|
||||
|
||||
if (mode === "edit" && !variable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<FormProvider {...form}>
|
||||
<form
|
||||
className="mt-5"
|
||||
onSubmit={form.handleSubmit((data) => {
|
||||
if (mode === "create") {
|
||||
createSurveyVariable(data);
|
||||
} else {
|
||||
editSurveyVariable(data);
|
||||
}
|
||||
})}>
|
||||
{mode === "create" && <Label htmlFor="headline">Add variable</Label>}
|
||||
|
||||
<div className="mt-2 flex w-full items-center gap-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
rules={{
|
||||
pattern: {
|
||||
value: /^[a-z0-9_]+$/,
|
||||
message: "Only lower case letters, numbers, and underscores are allowed.",
|
||||
},
|
||||
validate: (value) => {
|
||||
// if the variable name is already taken
|
||||
if (
|
||||
mode === "create" &&
|
||||
localSurvey.variables.find((variable) => variable.name === value)
|
||||
) {
|
||||
return "Variable name is already taken, please choose another.";
|
||||
}
|
||||
|
||||
if (mode === "edit" && variable && variable.name !== value) {
|
||||
if (localSurvey.variables.find((variable) => variable.name === value)) {
|
||||
return "Variable name is already taken, please choose another.";
|
||||
}
|
||||
}
|
||||
|
||||
// if it does not start with a letter
|
||||
if (!/^[a-z]/.test(value)) {
|
||||
return "Variable name must start with a letter.";
|
||||
}
|
||||
},
|
||||
}}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
isInvalid={isNameError}
|
||||
type="text"
|
||||
placeholder="Field name e.g, score, price"
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="type"
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
{...field}
|
||||
onValueChange={(value) => {
|
||||
form.setValue("value", value === "number" ? 0 : "");
|
||||
field.onChange(value);
|
||||
}}>
|
||||
<SelectTrigger className="w-24">
|
||||
<SelectValue placeholder="Select type" className="text-sm" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={"number"}>Number</SelectItem>
|
||||
<SelectItem value={"text"}>Text</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
|
||||
<p className="text-slate-600">=</p>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="value"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
field.onChange(variableType === "number" ? Number(e.target.value) : e.target.value);
|
||||
}}
|
||||
placeholder="Initial value"
|
||||
type={variableType === "number" ? "number" : "text"}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{mode === "create" && (
|
||||
<Button variant="secondary" type="submit" className="h-10 whitespace-nowrap">
|
||||
Add variable
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{mode === "edit" && variable && (
|
||||
<Button
|
||||
variant="minimal"
|
||||
type="button"
|
||||
size="sm"
|
||||
className="whitespace-nowrap"
|
||||
onClick={() => onVaribleDelete(variable)}>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isNameError && <p className="mt-1 text-sm text-red-500">{errors.name?.message}</p>}
|
||||
</form>
|
||||
</FormProvider>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -37,4 +37,5 @@ export const minimalSurvey: TSurvey = {
|
||||
languages: [],
|
||||
showLanguageSwitch: false,
|
||||
isVerifyEmailEnabled: false,
|
||||
variables: [],
|
||||
};
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import { getEnvironment } from "@formbricks/lib/environment/service";
|
||||
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
|
||||
import { getAccessFlags } from "@formbricks/lib/membership/utils";
|
||||
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
|
||||
import { getUser } from "@formbricks/lib/user/service";
|
||||
import { TProductConfigChannel, TProductConfigIndustry } from "@formbricks/types/product";
|
||||
@@ -43,6 +46,15 @@ const Page = async ({ params, searchParams }: SurveyTemplateProps) => {
|
||||
if (!environment) {
|
||||
throw new Error("Environment not found");
|
||||
}
|
||||
const currentUserMembership = await getMembershipByUserIdOrganizationId(
|
||||
session?.user.id,
|
||||
product.organizationId
|
||||
);
|
||||
const { isViewer } = getAccessFlags(currentUserMembership?.role);
|
||||
|
||||
if (isViewer) {
|
||||
return redirect(`/environments/${environment.id}/surveys`);
|
||||
}
|
||||
|
||||
const prefilledFilters = [product.config.channel, product.config.industry, searchParams.role ?? null];
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from "@formbricks/lib/organization/utils";
|
||||
import { getSegmentsByAttributeClassName } from "@formbricks/lib/segment/service";
|
||||
import { ZAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import { ZId } from "@formbricks/types/environment";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
|
||||
const ZGetSegmentsByAttributeClassAction = z.object({
|
||||
environmentId: ZId,
|
||||
|
||||
@@ -5,7 +5,7 @@ import { authenticatedActionClient } from "@formbricks/lib/actionClient";
|
||||
import { checkAuthorization } from "@formbricks/lib/actionClient/utils";
|
||||
import { getOrganizationIdFromPersonId } from "@formbricks/lib/organization/utils";
|
||||
import { deletePerson } from "@formbricks/lib/person/service";
|
||||
import { ZId } from "@formbricks/types/environment";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
|
||||
const ZPersonDeleteAction = z.object({
|
||||
personId: ZId,
|
||||
|
||||
@@ -5,7 +5,7 @@ import { authenticatedActionClient } from "@formbricks/lib/actionClient";
|
||||
import { checkAuthorization } from "@formbricks/lib/actionClient/utils";
|
||||
import { getOrganizationIdFromSegmentId } from "@formbricks/lib/organization/utils";
|
||||
import { deleteSegment, updateSegment } from "@formbricks/lib/segment/service";
|
||||
import { ZId } from "@formbricks/types/environment";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { ZSegmentFilters, ZSegmentUpdateInput } from "@formbricks/types/segment";
|
||||
|
||||
const ZDeleteBasicSegmentAction = z.object({
|
||||
|
||||
@@ -8,7 +8,7 @@ import { createMembership } from "@formbricks/lib/membership/service";
|
||||
import { createOrganization } from "@formbricks/lib/organization/service";
|
||||
import { createProduct } from "@formbricks/lib/product/service";
|
||||
import { updateUser } from "@formbricks/lib/user/service";
|
||||
import { ZId } from "@formbricks/types/environment";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { OperationNotAllowedError } from "@formbricks/types/errors";
|
||||
import { ZProductUpdateInput } from "@formbricks/types/product";
|
||||
import { TUserNotificationSettings } from "@formbricks/types/user";
|
||||
|
||||
@@ -7,7 +7,7 @@ import { checkAuthorization } from "@formbricks/lib/actionClient/utils";
|
||||
import { getOrganizationIdFromActionClassId } from "@formbricks/lib/organization/utils";
|
||||
import { getSurveysByActionClassId } from "@formbricks/lib/survey/service";
|
||||
import { ZActionClassInput } from "@formbricks/types/action-classes";
|
||||
import { ZId } from "@formbricks/types/environment";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
|
||||
const ZDeleteActionClassAction = z.object({
|
||||
|
||||
@@ -102,6 +102,7 @@ export const EnvironmentLayout = async ({ environmentId, session, children }: En
|
||||
environment={environment}
|
||||
environments={environments}
|
||||
currentProductChannel={currentProductChannel}
|
||||
membershipRole={currentUserMembership?.role}
|
||||
/>
|
||||
<div className="mt-14">{children}</div>
|
||||
</div>
|
||||
|
||||
@@ -2,15 +2,22 @@ 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 { TMembershipRole } from "@formbricks/types/memberships";
|
||||
import { TProductConfigChannel } from "@formbricks/types/product";
|
||||
|
||||
interface SideBarProps {
|
||||
environment: TEnvironment;
|
||||
environments: TEnvironment[];
|
||||
currentProductChannel: TProductConfigChannel;
|
||||
membershipRole?: TMembershipRole;
|
||||
}
|
||||
|
||||
export const TopControlBar = ({ environment, environments, currentProductChannel }: SideBarProps) => {
|
||||
export const TopControlBar = ({
|
||||
environment,
|
||||
environments,
|
||||
currentProductChannel,
|
||||
membershipRole,
|
||||
}: 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">
|
||||
@@ -28,6 +35,7 @@ export const TopControlBar = ({ environment, environments, currentProductChannel
|
||||
environment={environment}
|
||||
environments={environments}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
membershipRole={membershipRole}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,18 +5,21 @@ import { CircleUserIcon, MessageCircleQuestionIcon, PlusIcon } from "lucide-reac
|
||||
import { useRouter } from "next/navigation";
|
||||
import formbricks from "@formbricks/js/app";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TMembershipRole } from "@formbricks/types/memberships";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
|
||||
interface TopControlButtonsProps {
|
||||
environment: TEnvironment;
|
||||
environments: TEnvironment[];
|
||||
isFormbricksCloud: boolean;
|
||||
membershipRole?: TMembershipRole;
|
||||
}
|
||||
|
||||
export const TopControlButtons = ({
|
||||
environment,
|
||||
environments,
|
||||
isFormbricksCloud,
|
||||
membershipRole,
|
||||
}: TopControlButtonsProps) => {
|
||||
const router = useRouter();
|
||||
return (
|
||||
@@ -44,16 +47,18 @@ export const TopControlButtons = ({
|
||||
}}>
|
||||
<CircleUserIcon strokeWidth={1.5} className="h-5 w-5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
tooltip="New survey"
|
||||
className="h-fit w-fit p-1"
|
||||
onClick={() => {
|
||||
router.push(`/environments/${environment.id}/surveys/templates`);
|
||||
}}>
|
||||
<PlusIcon strokeWidth={1.5} className="h-5 w-5" />
|
||||
</Button>
|
||||
{membershipRole && membershipRole !== "viewer" ? (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
tooltip="New survey"
|
||||
className="h-fit w-fit p-1"
|
||||
onClick={() => {
|
||||
router.push(`/environments/${environment.id}/surveys/templates`);
|
||||
}}>
|
||||
<PlusIcon strokeWidth={1.5} className="h-5 w-5" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,7 +5,7 @@ import { authenticatedActionClient } from "@formbricks/lib/actionClient";
|
||||
import { checkAuthorization } from "@formbricks/lib/actionClient/utils";
|
||||
import { createOrUpdateIntegration, deleteIntegration } from "@formbricks/lib/integration/service";
|
||||
import { getOrganizationIdFromEnvironmentId } from "@formbricks/lib/organization/utils";
|
||||
import { ZId } from "@formbricks/types/environment";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { ZIntegrationInput } from "@formbricks/types/integration";
|
||||
|
||||
const ZCreateOrUpdateIntegrationAction = z.object({
|
||||
|
||||
@@ -5,7 +5,7 @@ import { authenticatedActionClient } from "@formbricks/lib/actionClient";
|
||||
import { checkAuthorization } from "@formbricks/lib/actionClient/utils";
|
||||
import { getOrganizationIdFromEnvironmentId } from "@formbricks/lib/organization/utils";
|
||||
import { getSlackChannels } from "@formbricks/lib/slack/service";
|
||||
import { ZId } from "@formbricks/types/environment";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
|
||||
const ZRefreshChannelsAction = z.object({
|
||||
environmentId: ZId,
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from "@formbricks/lib/organization/utils";
|
||||
import { createWebhook, deleteWebhook, updateWebhook } from "@formbricks/lib/webhook/service";
|
||||
import { testEndpoint } from "@formbricks/lib/webhook/utils";
|
||||
import { ZId } from "@formbricks/types/environment";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { ZWebhookInput } from "@formbricks/types/webhooks";
|
||||
|
||||
const ZCreateWebhookAction = z.object({
|
||||
|
||||
@@ -5,7 +5,7 @@ import { authenticatedActionClient } from "@formbricks/lib/actionClient";
|
||||
import { checkAuthorization } from "@formbricks/lib/actionClient/utils";
|
||||
import { getOrganizationIdFromProductId } from "@formbricks/lib/organization/utils";
|
||||
import { updateProduct } from "@formbricks/lib/product/service";
|
||||
import { ZId } from "@formbricks/types/environment";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { ZProductUpdateInput } from "@formbricks/types/product";
|
||||
|
||||
const ZUpdateProductAction = z.object({
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
getOrganizationIdFromEnvironmentId,
|
||||
} from "@formbricks/lib/organization/utils";
|
||||
import { ZApiKeyCreateInput } from "@formbricks/types/api-keys";
|
||||
import { ZId } from "@formbricks/types/environment";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
|
||||
const ZDeleteApiKeyAction = z.object({
|
||||
id: ZId,
|
||||
|
||||
@@ -5,7 +5,7 @@ import { authenticatedActionClient } from "@formbricks/lib/actionClient";
|
||||
import { checkAuthorization } from "@formbricks/lib/actionClient/utils";
|
||||
import { getOrganizationIdFromProductId } from "@formbricks/lib/organization/utils";
|
||||
import { deleteProduct, getProducts } from "@formbricks/lib/product/service";
|
||||
import { ZId } from "@formbricks/types/environment";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
|
||||
const ZProductDeleteAction = z.object({
|
||||
productId: ZId,
|
||||
|
||||
@@ -120,7 +120,8 @@ export const EditLogo = ({ product, environmentId, isViewer }: EditLogoProps) =>
|
||||
allowedFileExtensions={["png", "jpeg", "jpg"]}
|
||||
environmentId={environmentId}
|
||||
onFileUpload={(files: string[]) => {
|
||||
setLogoUrl(files[0]), setIsEditing(true);
|
||||
setLogoUrl(files[0]);
|
||||
setIsEditing(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -108,8 +108,6 @@ export const ThemeStylingPreviewSurvey = ({
|
||||
setQuestionId(survey?.questions[0]?.id);
|
||||
};
|
||||
|
||||
const onFileUpload = async (file: File) => file.name;
|
||||
|
||||
const isAppSurvey = previewType === "app" || previewType === "website";
|
||||
|
||||
const scrollToEditLogoSection = () => {
|
||||
@@ -168,7 +166,7 @@ export const ThemeStylingPreviewSurvey = ({
|
||||
survey={{ ...survey, type: "app" }}
|
||||
isBrandingEnabled={product.inAppSurveyBranding}
|
||||
isRedirectDisabled={true}
|
||||
onFileUpload={onFileUpload}
|
||||
onFileUpload={async (file) => file.name}
|
||||
styling={product.styling}
|
||||
isCardBorderVisible={!highlightBorderColor}
|
||||
languageCode="default"
|
||||
@@ -190,7 +188,7 @@ export const ThemeStylingPreviewSurvey = ({
|
||||
survey={{ ...survey, type: "link" }}
|
||||
isBrandingEnabled={product.linkSurveyBranding}
|
||||
isRedirectDisabled={true}
|
||||
onFileUpload={onFileUpload}
|
||||
onFileUpload={async (file) => file.name}
|
||||
responseCount={42}
|
||||
styling={product.styling}
|
||||
languageCode="default"
|
||||
|
||||
@@ -5,7 +5,7 @@ import { authenticatedActionClient } from "@formbricks/lib/actionClient";
|
||||
import { checkAuthorization } from "@formbricks/lib/actionClient/utils";
|
||||
import { getOrganizationIdFromTagId } from "@formbricks/lib/organization/utils";
|
||||
import { deleteTag, mergeTags, updateTagName } from "@formbricks/lib/tag/service";
|
||||
import { ZId } from "@formbricks/types/environment";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
|
||||
const ZDeleteTagAction = z.object({
|
||||
tagId: ZId,
|
||||
|
||||
@@ -57,7 +57,7 @@ const Page = async ({ params }) => {
|
||||
productChannel={currentProductChannel}
|
||||
/>
|
||||
</PageHeader>
|
||||
<SettingsCard title="Manage Tags" description="Add, merge and remove response tags.">
|
||||
<SettingsCard title="Manage Tags" description="Merge and remove response tags.">
|
||||
<EditTagsWrapper
|
||||
environment={environment}
|
||||
environmentTags={tags}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { getOrganizationIdFromEnvironmentId } from "@formbricks/lib/organization
|
||||
import { deleteFile } from "@formbricks/lib/storage/service";
|
||||
import { getFileNameWithIdFromUrl } from "@formbricks/lib/storage/utils";
|
||||
import { updateUser } from "@formbricks/lib/user/service";
|
||||
import { ZId } from "@formbricks/types/environment";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { ZUserUpdateInput } from "@formbricks/types/user";
|
||||
|
||||
export const updateUserAction = authenticatedActionClient
|
||||
|
||||
@@ -9,7 +9,7 @@ import { checkAuthorization } from "@formbricks/lib/actionClient/utils";
|
||||
import { STRIPE_PRICE_LOOKUP_KEYS } from "@formbricks/lib/constants";
|
||||
import { WEBAPP_URL } from "@formbricks/lib/constants";
|
||||
import { getOrganization } from "@formbricks/lib/organization/service";
|
||||
import { ZId } from "@formbricks/types/environment";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
|
||||
const ZUpgradePlanAction = z.object({
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
} from "@formbricks/lib/membership/service";
|
||||
import { deleteOrganization, updateOrganization } from "@formbricks/lib/organization/service";
|
||||
import { getOrganizationIdFromInviteId } from "@formbricks/lib/organization/utils";
|
||||
import { ZId } from "@formbricks/types/environment";
|
||||
import { ZId, ZUuid } from "@formbricks/types/common";
|
||||
import { AuthenticationError, OperationNotAllowedError, ValidationError } from "@formbricks/types/errors";
|
||||
import { ZMembershipRole } from "@formbricks/types/memberships";
|
||||
import { ZOrganizationUpdateInput } from "@formbricks/types/organizations";
|
||||
@@ -39,7 +39,7 @@ export const updateOrganizationNameAction = authenticatedActionClient
|
||||
});
|
||||
|
||||
const ZDeleteInviteAction = z.object({
|
||||
inviteId: ZId,
|
||||
inviteId: ZUuid,
|
||||
organizationId: ZId,
|
||||
});
|
||||
|
||||
@@ -130,7 +130,7 @@ export const createInviteTokenAction = authenticatedActionClient
|
||||
});
|
||||
|
||||
const ZResendInviteAction = z.object({
|
||||
inviteId: ZId,
|
||||
inviteId: ZUuid,
|
||||
organizationId: ZId,
|
||||
});
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { authenticatedActionClient } from "@formbricks/lib/actionClient";
|
||||
import { checkAuthorization } from "@formbricks/lib/actionClient/utils";
|
||||
import { getOrganizationIdFromSurveyId } from "@formbricks/lib/organization/utils";
|
||||
import { getResponseCountBySurveyId, getResponses, getSurveySummary } from "@formbricks/lib/response/service";
|
||||
import { ZId } from "@formbricks/types/environment";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { ZResponseFilterCriteria } from "@formbricks/types/responses";
|
||||
|
||||
export const revalidateSurveyIdPath = async (environmentId: string, surveyId: string) => {
|
||||
|
||||
@@ -8,7 +8,7 @@ import { authenticatedActionClient } from "@formbricks/lib/actionClient";
|
||||
import { checkAuthorization } from "@formbricks/lib/actionClient/utils";
|
||||
import { getOrganizationIdFromSurveyId } from "@formbricks/lib/organization/utils";
|
||||
import { getSurvey, updateSurvey } from "@formbricks/lib/survey/service";
|
||||
import { ZId } from "@formbricks/types/environment";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
|
||||
const ZSendEmbedSurveyPreviewEmailAction = z.object({
|
||||
|
||||
@@ -39,7 +39,7 @@ export const MultipleChoiceSummary = ({
|
||||
setFilter,
|
||||
}: MultipleChoiceSummaryProps) => {
|
||||
const [visibleOtherResponses, setVisibleOtherResponses] = useState(10);
|
||||
|
||||
const otherValue = questionSummary.question.choices.find((choice) => choice.id === "other")?.label.default;
|
||||
// sort by count and transform to array
|
||||
const results = Object.values(questionSummary.choices).sort((a, b) => {
|
||||
if (a.others) return 1; // Always put a after b if a has 'others'
|
||||
@@ -77,7 +77,9 @@ export const MultipleChoiceSummary = ({
|
||||
questionSummary.question.id,
|
||||
questionSummary.question.headline,
|
||||
questionSummary.question.type,
|
||||
questionSummary.type === "multipleChoiceSingle" ? "Includes either" : "Includes all",
|
||||
questionSummary.type === "multipleChoiceSingle" || otherValue === result.value
|
||||
? "Includes either"
|
||||
: "Includes all",
|
||||
[result.value]
|
||||
)
|
||||
}>
|
||||
|
||||
@@ -59,7 +59,7 @@ export const OpenTextSummary = ({
|
||||
survey={survey}
|
||||
attributeClasses={attributeClasses}
|
||||
/>
|
||||
<SecondaryNavigation activeId={activeTab} navigation={tabNavigation} />
|
||||
{isAiEnabled && <SecondaryNavigation activeId={activeTab} navigation={tabNavigation} />}
|
||||
<div className="max-h-[40vh] overflow-y-auto">
|
||||
{activeTab === "insights" ? (
|
||||
<Table className="border-t border-slate-200">
|
||||
|
||||
@@ -70,13 +70,14 @@ export const SurveyAnalysisCTA = ({
|
||||
onClick={() => {
|
||||
setOpenShareSurveyModal(true);
|
||||
}}>
|
||||
<ShareIcon className="h-5 w-5" />
|
||||
Share
|
||||
<ShareIcon className="ml-1 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
{!isViewer && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-full w-full px-3"
|
||||
className="h-full"
|
||||
href={`/environments/${environment.id}/surveys/${survey.id}/edit`}>
|
||||
Edit
|
||||
<SquarePenIcon className="ml-1 h-4" />
|
||||
|
||||
@@ -7,7 +7,7 @@ import { getOrganizationIdFromSurveyId } from "@formbricks/lib/organization/util
|
||||
import { getResponseDownloadUrl, getResponseFilteringValues } from "@formbricks/lib/response/service";
|
||||
import { getSurvey, updateSurvey } from "@formbricks/lib/survey/service";
|
||||
import { getTagsByEnvironmentId } from "@formbricks/lib/tag/service";
|
||||
import { ZId } from "@formbricks/types/environment";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { ZResponseFilterCriteria } from "@formbricks/types/responses";
|
||||
import { ZSurvey } from "@formbricks/types/surveys/types";
|
||||
|
||||
@@ -57,7 +57,7 @@ export const SurveyStatusDropdown = ({
|
||||
<TooltipProvider delayDuration={50}>
|
||||
<Tooltip open={isStatusChangeDisabled ? undefined : false}>
|
||||
<TooltipTrigger asChild>
|
||||
<SelectTrigger className="w-[170px] bg-white py-6 md:w-[200px]">
|
||||
<SelectTrigger className="w-[170px] bg-white md:w-[200px]">
|
||||
<SelectValue>
|
||||
<div className="flex items-center">
|
||||
{(survey.type === "link" ||
|
||||
|
||||
@@ -90,6 +90,15 @@ const Page = async ({ params, searchParams }: SurveyTemplateProps) => {
|
||||
currentProductChannel={currentProductChannel}
|
||||
/>
|
||||
</>
|
||||
) : isViewer ? (
|
||||
<>
|
||||
<h1 className="px-6 text-3xl font-extrabold text-slate-700">No surveys created yet.</h1>
|
||||
|
||||
<h2 className="px-6 text-lg font-medium text-slate-500">
|
||||
As a Viewer you are not allowed to create surveys. Please ask an Editor to create a survey or an
|
||||
Admin to upgrade your role.
|
||||
</h2>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h1 className="px-6 text-3xl font-extrabold text-slate-700">
|
||||
|
||||
@@ -2,6 +2,7 @@ import { responses } from "@/app/lib/api/response";
|
||||
import { headers } from "next/headers";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { CRON_SECRET } from "@formbricks/lib/constants";
|
||||
import { surveyCache } from "@formbricks/lib/survey/cache";
|
||||
|
||||
export const POST = async () => {
|
||||
const headersList = headers();
|
||||
@@ -21,6 +22,7 @@ export const POST = async () => {
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
environmentId: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -47,6 +49,7 @@ export const POST = async () => {
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
environmentId: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -63,6 +66,15 @@ export const POST = async () => {
|
||||
});
|
||||
}
|
||||
|
||||
const updatedSurveys = [...surveysToClose, ...scheduledSurveys];
|
||||
|
||||
for (const survey of updatedSurveys) {
|
||||
surveyCache.revalidate({
|
||||
id: survey.id,
|
||||
environmentId: survey.environmentId,
|
||||
});
|
||||
}
|
||||
|
||||
return responses.successResponse({
|
||||
message: `Updated ${surveysToClose.length} surveys to completed and ${scheduledSurveys.length} surveys to inProgress.`,
|
||||
});
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
|
||||
import { convertResponseValue } from "@formbricks/lib/responses";
|
||||
import { replaceHeadlineRecall } from "@formbricks/lib/utils/recall";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import {
|
||||
TWeeklyEmailResponseData,
|
||||
TWeeklySummaryEnvironmentData,
|
||||
TWeeklySummaryNotificationDataSurvey,
|
||||
TWeeklySummaryNotificationResponse,
|
||||
@@ -23,7 +25,11 @@ export const getNotificationResponse = (
|
||||
const surveys: TWeeklySummaryNotificationDataSurvey[] = [];
|
||||
// iterate through the surveys and calculate the overall insights
|
||||
for (const survey of environment.surveys) {
|
||||
const parsedSurvey = replaceHeadlineRecall(survey, "default", environment.attributeClasses);
|
||||
const parsedSurvey = replaceHeadlineRecall(
|
||||
survey as unknown as TSurvey,
|
||||
"default",
|
||||
environment.attributeClasses
|
||||
) as TSurvey & { responses: TWeeklyEmailResponseData[] };
|
||||
const surveyData: TWeeklySummaryNotificationDataSurvey = {
|
||||
id: parsedSurvey.id,
|
||||
name: parsedSurvey.name,
|
||||
|
||||
@@ -47,12 +47,10 @@ export const GET = async (
|
||||
const version = request.nextUrl.searchParams.get("version");
|
||||
|
||||
// validate using zod
|
||||
|
||||
const inputValidation = ZJsPeopleUserIdInput.safeParse({
|
||||
environmentId: params.environmentId,
|
||||
userId: params.userId,
|
||||
});
|
||||
|
||||
if (!inputValidation.success) {
|
||||
return responses.badRequestResponse(
|
||||
"Fields are missing or incorrectly formatted",
|
||||
|
||||
@@ -76,6 +76,7 @@ export const PUT = async (
|
||||
true
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
if (err.statusCode === 403) {
|
||||
return responses.forbiddenResponse(err.message || "Forbidden", true, { ignore: true });
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { getPerson } from "@formbricks/lib/person/service";
|
||||
import { capturePosthogEnvironmentEvent } from "@formbricks/lib/posthogServer";
|
||||
import { createResponse } from "@formbricks/lib/response/service";
|
||||
import { getSurvey } from "@formbricks/lib/survey/service";
|
||||
import { ZId } from "@formbricks/types/environment";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { InvalidInputError } from "@formbricks/types/errors";
|
||||
import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/responses";
|
||||
|
||||
|
||||
@@ -100,8 +100,11 @@ export const POST = async (req: NextRequest, context: Context): Promise<Response
|
||||
return responses.unauthorizedResponse();
|
||||
}
|
||||
|
||||
const formData = await req.formData();
|
||||
const file = formData.get("file") as unknown as File;
|
||||
const formData = await req.json();
|
||||
const base64String = formData.fileBase64String as string;
|
||||
|
||||
const buffer = Buffer.from(base64String.split(",")[1], "base64");
|
||||
const file = new Blob([buffer], { type: fileType });
|
||||
|
||||
if (!file) {
|
||||
return responses.badRequestResponse("fileBuffer is required");
|
||||
@@ -125,6 +128,7 @@ export const POST = async (req: NextRequest, context: Context): Promise<Response
|
||||
message: "File uploaded successfully",
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("err: ", err);
|
||||
if (err.name === "FileTooLargeError") {
|
||||
return responses.badRequestResponse(err.message);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { authenticateRequest, handleErrorResponse } from "@/app/api/v1/auth";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { NextRequest } from "next/server";
|
||||
import { getSurvey } from "@formbricks/lib/survey/service";
|
||||
import { generateSurveySingleUseIds } from "@formbricks/lib/utils/singleUseSurveys";
|
||||
|
||||
export const GET = async (
|
||||
request: NextRequest,
|
||||
{ params }: { params: { surveyId: string } }
|
||||
): Promise<Response> => {
|
||||
try {
|
||||
const authentication = await authenticateRequest(request);
|
||||
if (!authentication) return responses.notAuthenticatedResponse();
|
||||
const survey = await getSurvey(params.surveyId);
|
||||
if (!survey) {
|
||||
return responses.notFoundResponse("Survey", params.surveyId);
|
||||
}
|
||||
if (survey.environmentId !== authentication.environmentId) {
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
|
||||
if (!survey.singleUse || !survey.singleUse.enabled) {
|
||||
return responses.badRequestResponse("Single use links are not enabled for this survey");
|
||||
}
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const limit = searchParams.get("limit") ? Number(searchParams.get("limit")) : 10;
|
||||
|
||||
if (limit < 1) {
|
||||
return responses.badRequestResponse("Limit cannot be less than 1");
|
||||
}
|
||||
|
||||
if (limit > 5000) {
|
||||
return responses.badRequestResponse("Limit cannot be more than 5000");
|
||||
}
|
||||
|
||||
const singleUseIds = generateSurveySingleUseIds(limit, survey.singleUse.isEncrypted);
|
||||
|
||||
// map single use ids to survey links
|
||||
const surveyLinks = singleUseIds.map(
|
||||
(singleUseId) => `${process.env.WEBAPP_URL}/s/${survey.id}?suId=${singleUseId}`
|
||||
);
|
||||
|
||||
return responses.successResponse(surveyLinks);
|
||||
} catch (error) {
|
||||
return handleErrorResponse(error);
|
||||
}
|
||||
};
|
||||
@@ -132,7 +132,7 @@ export const questionTypes: TQuestion[] = [
|
||||
},
|
||||
{
|
||||
id: QuestionId.CTA,
|
||||
label: "Call-to-Action",
|
||||
label: "Call-to-Action (Statement)",
|
||||
description: "Prompt respondents to perform an action",
|
||||
icon: MousePointerClickIcon,
|
||||
preset: {
|
||||
|
||||
@@ -72,10 +72,14 @@ export const generateQuestionAndFilterOptions = (
|
||||
questionOptions = [...questionOptions, { header: OptionsType.QUESTIONS, option: questionsOptions }];
|
||||
survey.questions.forEach((q) => {
|
||||
if (Object.keys(conditionOptions).includes(q.type)) {
|
||||
if (
|
||||
q.type === TSurveyQuestionTypeEnum.MultipleChoiceMulti ||
|
||||
q.type === TSurveyQuestionTypeEnum.MultipleChoiceSingle
|
||||
) {
|
||||
if (q.type === TSurveyQuestionTypeEnum.MultipleChoiceSingle) {
|
||||
questionFilterOptions.push({
|
||||
type: q.type,
|
||||
filterOptions: conditionOptions[q.type],
|
||||
filterComboBoxOptions: q?.choices ? q?.choices?.map((c) => c?.label) : [""],
|
||||
id: q.id,
|
||||
});
|
||||
} else if (q.type === TSurveyQuestionTypeEnum.MultipleChoiceMulti) {
|
||||
questionFilterOptions.push({
|
||||
type: q.type,
|
||||
filterOptions: conditionOptions[q.type],
|
||||
|
||||
@@ -6,8 +6,8 @@ import { sendLinkSurveyToVerifiedEmail } from "@formbricks/email";
|
||||
import { actionClient } from "@formbricks/lib/actionClient";
|
||||
import { verifyTokenForLinkSurvey } from "@formbricks/lib/jwt";
|
||||
import { getSurvey } from "@formbricks/lib/survey/service";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { ZLinkSurveyEmailData } from "@formbricks/types/email";
|
||||
import { ZId } from "@formbricks/types/environment";
|
||||
|
||||
export const sendLinkSurveyEmailAction = actionClient
|
||||
.schema(ZLinkSurveyEmailData)
|
||||
|
||||
@@ -10,6 +10,7 @@ import { FormbricksAPI } from "@formbricks/api";
|
||||
import { ResponseQueue } from "@formbricks/lib/responseQueue";
|
||||
import { SurveyState } from "@formbricks/lib/surveyState";
|
||||
import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import { TJsFileUploadParams } from "@formbricks/types/js";
|
||||
import { TProduct } from "@formbricks/types/product";
|
||||
import { TResponse, TResponseHiddenFieldValue, TResponseUpdate } from "@formbricks/types/responses";
|
||||
import { TUploadFileConfig } from "@formbricks/types/storage";
|
||||
@@ -273,7 +274,7 @@ export const LinkSurvey = ({
|
||||
...(Object.keys(hiddenFieldsRecord).length > 0 && { hiddenFields: hiddenFieldsRecord }),
|
||||
});
|
||||
}}
|
||||
onFileUpload={async (file: File, params: TUploadFileConfig) => {
|
||||
onFileUpload={async (file: TJsFileUploadParams["file"], params: TUploadFileConfig) => {
|
||||
const api = new FormbricksAPI({
|
||||
apiHost: webAppUrl,
|
||||
environmentId: survey.environmentId,
|
||||
|
||||
@@ -13,7 +13,7 @@ import { createPerson, getPersonByUserId } from "@formbricks/lib/person/service"
|
||||
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
|
||||
import { getResponseBySingleUseId, getResponseCountBySurveyId } from "@formbricks/lib/response/service";
|
||||
import { getSurvey } from "@formbricks/lib/survey/service";
|
||||
import { ZId } from "@formbricks/types/environment";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { TResponse } from "@formbricks/types/responses";
|
||||
import { getEmailVerificationDetails } from "./lib/helpers";
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { checkAuthorization } from "@formbricks/lib/actionClient/utils";
|
||||
import { INVITE_DISABLED } from "@formbricks/lib/constants";
|
||||
import { inviteUser } from "@formbricks/lib/invite/service";
|
||||
import { getOrganizationsByUserId } from "@formbricks/lib/organization/service";
|
||||
import { ZId } from "@formbricks/types/environment";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { AuthenticationError } from "@formbricks/types/errors";
|
||||
|
||||
const ZInviteOrganizationMemberAction = z.object({
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from "@formbricks/lib/response/service";
|
||||
import { getSurveyIdByResultShareKey } from "@formbricks/lib/survey/service";
|
||||
import { getTagsByEnvironmentId } from "@formbricks/lib/tag/service";
|
||||
import { ZId } from "@formbricks/types/environment";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { AuthorizationError } from "@formbricks/types/errors";
|
||||
import { ZResponseFilterCriteria } from "@formbricks/types/responses";
|
||||
|
||||
|
||||
@@ -167,9 +167,11 @@ test.describe("Survey Create & Submit Response", async () => {
|
||||
await expect(page.getByRole("cell", { name: surveys.createAndSubmit.matrix.columns[1] })).toBeVisible();
|
||||
await expect(page.getByRole("cell", { name: surveys.createAndSubmit.matrix.columns[2] })).toBeVisible();
|
||||
await expect(page.getByRole("cell", { name: surveys.createAndSubmit.matrix.columns[3] })).toBeVisible();
|
||||
await expect(page.locator("#questionCard-9").getByRole("button", { name: "Next" })).not.toBeVisible();
|
||||
await expect(page.locator("#questionCard-9").getByRole("button", { name: "Next" })).toBeVisible();
|
||||
await expect(page.locator("#questionCard-9").getByRole("button", { name: "Back" })).toBeVisible();
|
||||
await page.getByRole("row", { name: "Rose 🌹" }).getByRole("cell").nth(1).click();
|
||||
await page.getByRole("row", { name: "Sunflower 🌻" }).getByRole("cell").nth(1).click();
|
||||
await page.getByRole("row", { name: "Hibiscus 🌺" }).getByRole("cell").nth(1).click();
|
||||
await page.locator("#questionCard-9").getByRole("button", { name: "Next" }).click();
|
||||
|
||||
// Address Question
|
||||
|
||||
@@ -35,10 +35,13 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@formbricks/config-typescript": "workspace:*",
|
||||
"@formbricks/types": "workspace:*",
|
||||
"@formbricks/eslint-config": "workspace:*",
|
||||
"@formbricks/types": "workspace:*",
|
||||
"@rollup/plugin-inject": "^5.0.5",
|
||||
"buffer": "^6.0.3",
|
||||
"terser": "^5.31.6",
|
||||
"vite": "^5.4.1",
|
||||
"vite-plugin-dts": "^3.9.1"
|
||||
"vite-plugin-dts": "^3.9.1",
|
||||
"vite-plugin-node-polyfills": "^0.22.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
/* eslint-disable no-console -- used for error logging */
|
||||
import { Buffer } from "node:buffer";
|
||||
import type { TUploadFileConfig, TUploadFileResponse } from "@formbricks/types/storage";
|
||||
|
||||
export class StorageAPI {
|
||||
@@ -10,11 +12,15 @@ export class StorageAPI {
|
||||
}
|
||||
|
||||
async uploadFile(
|
||||
file: File,
|
||||
file: {
|
||||
type: string;
|
||||
name: string;
|
||||
base64: string;
|
||||
},
|
||||
{ allowedFileExtensions, surveyId }: TUploadFileConfig | undefined = {}
|
||||
): Promise<string> {
|
||||
if (!(file instanceof Blob) || !(file instanceof File)) {
|
||||
throw new Error(`Invalid file type. Expected Blob or File, but received ${typeof file}`);
|
||||
if (!file.name || !file.type || !file.base64) {
|
||||
throw new Error(`Invalid file object`);
|
||||
}
|
||||
|
||||
const payload = {
|
||||
@@ -57,22 +63,47 @@ export class StorageAPI {
|
||||
};
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
const formData: Record<string, string> = {};
|
||||
const formDataForS3 = new FormData();
|
||||
|
||||
if (presignedFields) {
|
||||
Object.keys(presignedFields).forEach((key) => {
|
||||
formData.append(key, presignedFields[key]);
|
||||
formDataForS3.append(key, presignedFields[key]);
|
||||
});
|
||||
|
||||
try {
|
||||
const buffer = Buffer.from(file.base64.split(",")[1], "base64");
|
||||
const blob = new Blob([buffer], { type: file.type });
|
||||
|
||||
formDataForS3.append("file", blob);
|
||||
} catch (buffErr) {
|
||||
console.error({ buffErr });
|
||||
|
||||
throw new Error("Error uploading file");
|
||||
}
|
||||
}
|
||||
|
||||
// Add the actual file to be uploaded
|
||||
formData.append("file", file);
|
||||
formData.fileBase64String = file.base64;
|
||||
|
||||
const uploadResponse = await fetch(signedUrl, {
|
||||
method: "POST",
|
||||
...(signingData ? { headers: requestHeaders } : {}),
|
||||
body: formData,
|
||||
});
|
||||
let uploadResponse: Response = {} as Response;
|
||||
|
||||
const signedUrlCopy = signedUrl.replace("http://localhost:3000", this.apiHost);
|
||||
|
||||
try {
|
||||
uploadResponse = await fetch(signedUrlCopy, {
|
||||
method: "POST",
|
||||
...(signingData
|
||||
? {
|
||||
headers: {
|
||||
...requestHeaders,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
body: presignedFields ? formDataForS3 : JSON.stringify(formData),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Error uploading file", err);
|
||||
}
|
||||
|
||||
if (!uploadResponse.ok) {
|
||||
// if local storage is used, we'll use the json response:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { resolve } from "path";
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
import { nodePolyfills } from "vite-plugin-node-polyfills";
|
||||
|
||||
export default defineConfig({
|
||||
build: {
|
||||
@@ -16,5 +17,13 @@ export default defineConfig({
|
||||
fileName: "index",
|
||||
},
|
||||
},
|
||||
plugins: [dts({ rollupTypes: true })],
|
||||
plugins: [
|
||||
dts({ rollupTypes: true }),
|
||||
nodePolyfills({
|
||||
include: ["buffer"],
|
||||
globals: {
|
||||
Buffer: true,
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
@@ -6,6 +6,6 @@
|
||||
"jsx": "react-jsx",
|
||||
"lib": ["ES2015", "DOM"],
|
||||
"module": "ESNext",
|
||||
"target": "es6"
|
||||
"target": "ES2022"
|
||||
}
|
||||
}
|
||||
|
||||
13
packages/config-typescript/react-native-library.json
Normal file
13
packages/config-typescript/react-native-library.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"display": "React Native Library",
|
||||
"extends": "./base.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"jsx": "react-native",
|
||||
"lib": ["dom", "dom.iterable", "ES2022"],
|
||||
"noEmit": true,
|
||||
"resolveJsonModule": true,
|
||||
"target": "ESNext"
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
type TSurveyQuestions,
|
||||
type TSurveySingleUse,
|
||||
type TSurveyStyling,
|
||||
type TSurveyVariables,
|
||||
type TSurveyWelcomeCard,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { type TUserNotificationSettings } from "@formbricks/types/user";
|
||||
@@ -34,6 +35,7 @@ declare global {
|
||||
export type SurveyQuestions = TSurveyQuestions;
|
||||
export type SurveyEnding = TSurveyEnding;
|
||||
export type SurveyHiddenFields = TSurveyHiddenFields;
|
||||
export type SurveyVariables = TSurveyVariables;
|
||||
export type SurveyProductOverwrites = TSurveyProductOverwrites;
|
||||
export type SurveyStyling = TSurveyStyling;
|
||||
export type SurveyClosedMessage = TSurveyClosedMessage;
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Survey" ADD COLUMN "variables" JSONB NOT NULL DEFAULT '[]';
|
||||
@@ -281,6 +281,9 @@ model Survey {
|
||||
/// @zod.custom(imports.ZSurveyHiddenFields)
|
||||
/// [SurveyHiddenFields]
|
||||
hiddenFields Json @default("{\"enabled\": false}")
|
||||
/// @zod.custom(imports.ZSurveyVariables)
|
||||
/// [SurveyVariables]
|
||||
variables Json @default("[]")
|
||||
responses Response[]
|
||||
displayOption displayOptions @default(displayOnce)
|
||||
recontactDays Int?
|
||||
|
||||
@@ -15,6 +15,7 @@ export {
|
||||
ZSurveyWelcomeCard,
|
||||
ZSurveyQuestions,
|
||||
ZSurveyHiddenFields,
|
||||
ZSurveyVariables,
|
||||
ZSurveyClosedMessage,
|
||||
ZSurveyProductOverwrites,
|
||||
ZSurveyStyling,
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
updateSegment,
|
||||
} from "@formbricks/lib/segment/service";
|
||||
import { loadNewSegmentInSurvey } from "@formbricks/lib/survey/service";
|
||||
import { ZId } from "@formbricks/types/environment";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { ZSegmentCreateInput, ZSegmentFilters, ZSegmentUpdateInput } from "@formbricks/types/segment";
|
||||
|
||||
export const createSegmentAction = authenticatedActionClient
|
||||
|
||||
@@ -60,7 +60,7 @@ export function LocalizedEditor({
|
||||
disableLists
|
||||
excludedToolbarItems={["blockType"]}
|
||||
firstRender={firstRender}
|
||||
getText={() => md.render(value ? value[selectedLanguageCode] ?? "" : "")}
|
||||
getText={() => md.render(value ? (value[selectedLanguageCode] ?? "") : "")}
|
||||
key={`${questionIdx}-${selectedLanguageCode}`}
|
||||
setFirstRender={setFirstRender}
|
||||
setText={(v: string) => {
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
getOrganizationIdFromLanguageId,
|
||||
getOrganizationIdFromProductId,
|
||||
} from "@formbricks/lib/organization/utils";
|
||||
import { ZId } from "@formbricks/types/environment";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { ZLanguageInput } from "@formbricks/types/product";
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
transferOwnership,
|
||||
updateMembership,
|
||||
} from "@formbricks/lib/membership/service";
|
||||
import { ZId } from "@formbricks/types/environment";
|
||||
import { ZId, ZUuid } from "@formbricks/types/common";
|
||||
import { AuthorizationError, ValidationError } from "@formbricks/types/errors";
|
||||
import { ZInviteUpdateInput } from "@formbricks/types/invites";
|
||||
import { ZMembershipUpdateInput } from "@formbricks/types/memberships";
|
||||
@@ -44,7 +44,7 @@ export const transferOwnershipAction = authenticatedActionClient
|
||||
});
|
||||
|
||||
const ZUpdateInviteAction = z.object({
|
||||
inviteId: ZId,
|
||||
inviteId: ZUuid,
|
||||
organizationId: ZId,
|
||||
data: ZInviteUpdateInput,
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ import { sync } from "./sync";
|
||||
import { triggerSurvey } from "./widget";
|
||||
|
||||
const logger = Logger.getInstance();
|
||||
const inAppConfig = AppConfig.getInstance();
|
||||
const appConfig = AppConfig.getInstance();
|
||||
|
||||
export const trackAction = async (
|
||||
name: string,
|
||||
@@ -15,7 +15,7 @@ export const trackAction = async (
|
||||
properties?: TJsTrackProperties
|
||||
): Promise<Result<void, NetworkError>> => {
|
||||
const aliasName = alias || name;
|
||||
const { userId } = inAppConfig.get();
|
||||
const { userId } = appConfig.get();
|
||||
|
||||
if (userId) {
|
||||
// we skip the resync on a new action since this leads to too many requests if the user has a lot of actions
|
||||
@@ -25,12 +25,13 @@ export const trackAction = async (
|
||||
logger.debug(`Resync after action "${aliasName} in debug mode"`);
|
||||
await sync(
|
||||
{
|
||||
environmentId: inAppConfig.get().environmentId,
|
||||
apiHost: inAppConfig.get().apiHost,
|
||||
environmentId: appConfig.get().environmentId,
|
||||
apiHost: appConfig.get().apiHost,
|
||||
userId,
|
||||
attributes: inAppConfig.get().state.attributes,
|
||||
attributes: appConfig.get().state.attributes,
|
||||
},
|
||||
true
|
||||
true,
|
||||
appConfig
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -38,7 +39,7 @@ export const trackAction = async (
|
||||
logger.debug(`Formbricks: Action "${aliasName}" tracked`);
|
||||
|
||||
// get a list of surveys that are collecting insights
|
||||
const activeSurveys = inAppConfig.get().state?.surveys;
|
||||
const activeSurveys = appConfig.get().state?.surveys;
|
||||
|
||||
if (!!activeSurveys && activeSurveys.length > 0) {
|
||||
for (const survey of activeSurveys) {
|
||||
@@ -61,7 +62,7 @@ export const trackCodeAction = (
|
||||
): Promise<Result<void, NetworkError>> | Result<void, InvalidCodeError> => {
|
||||
const {
|
||||
state: { actionClasses = [] },
|
||||
} = inAppConfig.get();
|
||||
} = appConfig.get();
|
||||
|
||||
const codeActionClasses = actionClasses.filter((action) => action.type === "code");
|
||||
const action = codeActionClasses.find((action) => action.key === code);
|
||||
|
||||
@@ -4,12 +4,12 @@ import { MissingPersonError, NetworkError, Result, err, ok, okVoid } from "../..
|
||||
import { Logger } from "../../shared/logger";
|
||||
import { AppConfig } from "./config";
|
||||
|
||||
const appConfig = AppConfig.getInstance();
|
||||
const logger = Logger.getInstance();
|
||||
|
||||
export const updateAttribute = async (
|
||||
key: string,
|
||||
value: string | number
|
||||
value: string,
|
||||
appConfig: AppConfig
|
||||
): Promise<Result<void, NetworkError>> => {
|
||||
const { apiHost, environmentId, userId } = appConfig.get();
|
||||
|
||||
@@ -26,7 +26,6 @@ export const updateAttribute = async (
|
||||
logger.error(res.error.message ?? `Error updating person with userId ${userId}`);
|
||||
return okVoid();
|
||||
}
|
||||
|
||||
return err({
|
||||
code: "network_error",
|
||||
// @ts-expect-error
|
||||
@@ -48,7 +47,8 @@ export const updateAttributes = async (
|
||||
apiHost: string,
|
||||
environmentId: string,
|
||||
userId: string,
|
||||
attributes: TAttributes
|
||||
attributes: TAttributes,
|
||||
appConfig: AppConfig
|
||||
): Promise<Result<TAttributes, NetworkError>> => {
|
||||
// clean attributes and remove existing attributes if config already exists
|
||||
const updatedAttributes = { ...attributes };
|
||||
@@ -100,7 +100,7 @@ export const updateAttributes = async (
|
||||
}
|
||||
};
|
||||
|
||||
export const isExistingAttribute = (key: string, value: string): boolean => {
|
||||
export const isExistingAttribute = (key: string, value: string, appConfig: AppConfig): boolean => {
|
||||
if (appConfig.get().state.attributes[key] === value) {
|
||||
return true;
|
||||
}
|
||||
@@ -109,7 +109,8 @@ export const isExistingAttribute = (key: string, value: string): boolean => {
|
||||
|
||||
export const setAttributeInApp = async (
|
||||
key: string,
|
||||
value: any
|
||||
value: any,
|
||||
appConfig: AppConfig
|
||||
): Promise<Result<void, NetworkError | MissingPersonError>> => {
|
||||
if (key === "userId") {
|
||||
logger.error("Setting userId is no longer supported. Please set the userId in the init call instead.");
|
||||
@@ -118,12 +119,12 @@ export const setAttributeInApp = async (
|
||||
|
||||
logger.debug("Setting attribute: " + key + " to value: " + value);
|
||||
// check if attribute already exists with this value
|
||||
if (isExistingAttribute(key, value.toString())) {
|
||||
if (isExistingAttribute(key, value.toString(), appConfig)) {
|
||||
logger.debug("Attribute already set to this value. Skipping update.");
|
||||
return okVoid();
|
||||
}
|
||||
|
||||
const result = await updateAttribute(key, value);
|
||||
const result = await updateAttribute(key, value.toString(), appConfig);
|
||||
|
||||
if (result.ok) {
|
||||
// udpdate attribute in config
|
||||
|
||||
@@ -1,23 +1,51 @@
|
||||
import { TJSAppConfig, TJsAppConfigUpdateInput } from "@formbricks/types/js";
|
||||
import { APP_SURVEYS_LOCAL_STORAGE_KEY } from "../../shared/constants";
|
||||
import { Result, err, ok, wrapThrows } from "../../shared/errors";
|
||||
|
||||
export const IN_APP_LOCAL_STORAGE_KEY = "formbricks-js-app";
|
||||
export interface StorageHandler {
|
||||
getItem(key: string): Promise<string | null>;
|
||||
setItem(key: string, value: string): Promise<void>;
|
||||
removeItem(key: string): Promise<void>;
|
||||
}
|
||||
|
||||
// LocalStorage implementation - default
|
||||
class LocalStorage implements StorageHandler {
|
||||
async getItem(key: string): Promise<string | null> {
|
||||
return localStorage.getItem(key);
|
||||
}
|
||||
|
||||
async setItem(key: string, value: string): Promise<void> {
|
||||
localStorage.setItem(key, value);
|
||||
}
|
||||
|
||||
async removeItem(key: string): Promise<void> {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
}
|
||||
|
||||
export class AppConfig {
|
||||
private static instance: AppConfig | undefined;
|
||||
private config: TJSAppConfig | null = null;
|
||||
private storageHandler: StorageHandler;
|
||||
private storageKey: string;
|
||||
|
||||
private constructor() {
|
||||
const localConfig = this.loadFromLocalStorage();
|
||||
private constructor(
|
||||
storageHandler: StorageHandler = new LocalStorage(),
|
||||
storageKey: string = APP_SURVEYS_LOCAL_STORAGE_KEY
|
||||
) {
|
||||
this.storageHandler = storageHandler;
|
||||
this.storageKey = storageKey;
|
||||
|
||||
if (localConfig.ok) {
|
||||
this.config = localConfig.value;
|
||||
}
|
||||
this.loadFromStorage().then((res) => {
|
||||
if (res.ok) {
|
||||
this.config = res.value;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static getInstance(): AppConfig {
|
||||
static getInstance(storageHandler?: StorageHandler, storageKey?: string): AppConfig {
|
||||
if (!AppConfig.instance) {
|
||||
AppConfig.instance = new AppConfig();
|
||||
AppConfig.instance = new AppConfig(storageHandler, storageKey);
|
||||
}
|
||||
return AppConfig.instance;
|
||||
}
|
||||
@@ -30,7 +58,7 @@ export class AppConfig {
|
||||
status: newConfig.status || "success",
|
||||
};
|
||||
|
||||
this.saveToLocalStorage();
|
||||
this.saveToStorage();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,13 +69,10 @@ export class AppConfig {
|
||||
return this.config;
|
||||
}
|
||||
|
||||
public loadFromLocalStorage(): Result<TJSAppConfig, Error> {
|
||||
if (typeof window !== "undefined") {
|
||||
const savedConfig = localStorage.getItem(IN_APP_LOCAL_STORAGE_KEY);
|
||||
public async loadFromStorage(): Promise<Result<TJSAppConfig, Error>> {
|
||||
try {
|
||||
const savedConfig = await this.storageHandler.getItem(this.storageKey);
|
||||
if (savedConfig) {
|
||||
// TODO: validate config
|
||||
// This is a hack to get around the fact that we don't have a proper
|
||||
// way to validate the config yet.
|
||||
const parsedConfig = JSON.parse(savedConfig) as TJSAppConfig;
|
||||
|
||||
// check if the config has expired
|
||||
@@ -55,22 +80,29 @@ export class AppConfig {
|
||||
return err(new Error("Config in local storage has expired"));
|
||||
}
|
||||
|
||||
return ok(JSON.parse(savedConfig) as TJSAppConfig);
|
||||
return ok(parsedConfig);
|
||||
}
|
||||
} catch (e) {
|
||||
return err(new Error("No or invalid config in local storage"));
|
||||
}
|
||||
|
||||
return err(new Error("No or invalid config in local storage"));
|
||||
}
|
||||
|
||||
private saveToLocalStorage(): Result<void, Error> {
|
||||
return wrapThrows(() => localStorage.setItem(IN_APP_LOCAL_STORAGE_KEY, JSON.stringify(this.config)))();
|
||||
private async saveToStorage(): Promise<Result<Promise<void>, Error>> {
|
||||
return wrapThrows(async () => {
|
||||
await this.storageHandler.setItem(this.storageKey, JSON.stringify(this.config));
|
||||
})();
|
||||
}
|
||||
|
||||
// reset the config
|
||||
|
||||
public resetConfig(): Result<void, Error> {
|
||||
public async resetConfig(): Promise<Result<Promise<void>, Error>> {
|
||||
this.config = null;
|
||||
|
||||
return wrapThrows(() => localStorage.removeItem(IN_APP_LOCAL_STORAGE_KEY))();
|
||||
// return wrapThrows(() => localStorage.removeItem(IN_APP_LOCAL_STORAGE_KEY))();
|
||||
return wrapThrows(async () => {
|
||||
await this.storageHandler.removeItem(this.storageKey);
|
||||
})();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,12 +8,14 @@ import {
|
||||
removePageUrlEventListeners,
|
||||
removeScrollDepthListener,
|
||||
} from "../lib/noCodeActions";
|
||||
import { AppConfig } from "./config";
|
||||
import { addExpiryCheckListener, removeExpiryCheckListener } from "./sync";
|
||||
|
||||
let areRemoveEventListenersAdded = false;
|
||||
const appConfig = AppConfig.getInstance();
|
||||
|
||||
export const addEventListeners = (): void => {
|
||||
addExpiryCheckListener();
|
||||
addExpiryCheckListener(appConfig);
|
||||
addPageUrlEventListeners();
|
||||
addClickEventListener();
|
||||
addExitIntentListener();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { TAttributes } from "@formbricks/types/attributes";
|
||||
import type { TJSAppConfig, TJsAppConfigInput } from "@formbricks/types/js";
|
||||
import { APP_SURVEYS_LOCAL_STORAGE_KEY } from "../../shared/constants";
|
||||
import {
|
||||
ErrorHandler,
|
||||
MissingFieldError,
|
||||
@@ -15,7 +16,7 @@ import { Logger } from "../../shared/logger";
|
||||
import { getIsDebug } from "../../shared/utils";
|
||||
import { trackNoCodeAction } from "./actions";
|
||||
import { updateAttributes } from "./attributes";
|
||||
import { AppConfig, IN_APP_LOCAL_STORAGE_KEY } from "./config";
|
||||
import { AppConfig } from "./config";
|
||||
import { addCleanupEventListeners, addEventListeners, removeAllEventListeners } from "./eventListeners";
|
||||
import { checkPageUrl } from "./noCodeActions";
|
||||
import { sync } from "./sync";
|
||||
@@ -109,7 +110,8 @@ export const initialize = async (
|
||||
configInput.apiHost,
|
||||
configInput.environmentId,
|
||||
configInput.userId,
|
||||
configInput.attributes
|
||||
configInput.attributes,
|
||||
appConfig
|
||||
);
|
||||
if (res.ok !== true) {
|
||||
return err(res.error);
|
||||
@@ -130,11 +132,15 @@ export const initialize = async (
|
||||
logger.debug("Configuration expired.");
|
||||
|
||||
try {
|
||||
await sync({
|
||||
apiHost: configInput.apiHost,
|
||||
environmentId: configInput.environmentId,
|
||||
userId: configInput.userId,
|
||||
});
|
||||
await sync(
|
||||
{
|
||||
apiHost: configInput.apiHost,
|
||||
environmentId: configInput.environmentId,
|
||||
userId: configInput.userId,
|
||||
},
|
||||
undefined,
|
||||
appConfig
|
||||
);
|
||||
} catch (e) {
|
||||
putFormbricksInErrorState();
|
||||
}
|
||||
@@ -150,11 +156,15 @@ export const initialize = async (
|
||||
logger.debug("Syncing.");
|
||||
|
||||
try {
|
||||
await sync({
|
||||
apiHost: configInput.apiHost,
|
||||
environmentId: configInput.environmentId,
|
||||
userId: configInput.userId,
|
||||
});
|
||||
await sync(
|
||||
{
|
||||
apiHost: configInput.apiHost,
|
||||
environmentId: configInput.environmentId,
|
||||
userId: configInput.userId,
|
||||
},
|
||||
undefined,
|
||||
appConfig
|
||||
);
|
||||
} catch (e) {
|
||||
handleErrorOnFirstInit();
|
||||
}
|
||||
@@ -201,7 +211,7 @@ const handleErrorOnFirstInit = () => {
|
||||
expiresAt: new Date(new Date().getTime() + 10 * 60000), // 10 minutes in the future
|
||||
};
|
||||
// can't use config.update here because the config is not yet initialized
|
||||
wrapThrows(() => localStorage.setItem(IN_APP_LOCAL_STORAGE_KEY, JSON.stringify(initialErrorConfig)))();
|
||||
wrapThrows(() => localStorage.setItem(APP_SURVEYS_LOCAL_STORAGE_KEY, JSON.stringify(initialErrorConfig)))();
|
||||
throw new Error("Could not initialize formbricks");
|
||||
};
|
||||
|
||||
|
||||
@@ -3,10 +3,8 @@ import { TJsAppState, TJsAppStateSync, TJsAppSyncParams } from "@formbricks/type
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { NetworkError, Result, err, ok } from "../../shared/errors";
|
||||
import { Logger } from "../../shared/logger";
|
||||
import { getIsDebug } from "../../shared/utils";
|
||||
import { AppConfig } from "./config";
|
||||
|
||||
const appConfig = AppConfig.getInstance();
|
||||
const logger = Logger.getInstance();
|
||||
|
||||
let syncIntervalId: number | null = null;
|
||||
@@ -18,12 +16,12 @@ const syncWithBackend = async (
|
||||
try {
|
||||
let fetchOptions: RequestInit = {};
|
||||
|
||||
if (noCache || getIsDebug()) {
|
||||
if (noCache) {
|
||||
fetchOptions.cache = "no-cache";
|
||||
logger.debug("No cache option set for sync");
|
||||
}
|
||||
|
||||
const url = `${apiHost}/api/v1/client/${environmentId}/app/sync/${userId}?version=${import.meta.env.VERSION}`;
|
||||
logger.debug("syncing with backend");
|
||||
const url = `${apiHost}/api/v1/client/${environmentId}/app/sync/${userId}?version=2.0.0`;
|
||||
|
||||
const response = await fetch(url, fetchOptions);
|
||||
|
||||
@@ -48,7 +46,11 @@ const syncWithBackend = async (
|
||||
}
|
||||
};
|
||||
|
||||
export const sync = async (params: TJsAppSyncParams, noCache = false): Promise<void> => {
|
||||
export const sync = async (
|
||||
params: TJsAppSyncParams,
|
||||
noCache = false,
|
||||
appConfig: AppConfig
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const syncResult = await syncWithBackend(params, noCache);
|
||||
|
||||
@@ -85,7 +87,7 @@ export const sync = async (params: TJsAppSyncParams, noCache = false): Promise<v
|
||||
}
|
||||
};
|
||||
|
||||
export const addExpiryCheckListener = (): void => {
|
||||
export const addExpiryCheckListener = (appConfig: AppConfig): void => {
|
||||
const updateInterval = 1000 * 30; // every 30 seconds
|
||||
// add event listener to check sync with backend on regular interval
|
||||
if (typeof window !== "undefined" && syncIntervalId === null) {
|
||||
@@ -96,12 +98,16 @@ export const addExpiryCheckListener = (): void => {
|
||||
return;
|
||||
}
|
||||
logger.debug("Config has expired. Starting sync.");
|
||||
await sync({
|
||||
apiHost: appConfig.get().apiHost,
|
||||
environmentId: appConfig.get().environmentId,
|
||||
userId: appConfig.get().userId,
|
||||
attributes: appConfig.get().state.attributes,
|
||||
});
|
||||
await sync(
|
||||
{
|
||||
apiHost: appConfig.get().apiHost,
|
||||
environmentId: appConfig.get().environmentId,
|
||||
userId: appConfig.get().userId,
|
||||
attributes: appConfig.get().state.attributes,
|
||||
},
|
||||
false,
|
||||
appConfig
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(`Error during expiry check: ${e}`);
|
||||
logger.debug("Extending config and try again later.");
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user