feat: react native sdk (#2565)

Co-authored-by: tykerr <tykerr@gmail.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
Co-authored-by: Dhruwang Jariwala <67850763+Dhruwang@users.noreply.github.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
This commit is contained in:
ty kerr
2024-08-23 03:43:49 -07:00
committed by GitHub
parent ede306b88e
commit 89ffe99dcc
72 changed files with 6300 additions and 1071 deletions

View File

@@ -0,0 +1,2 @@
EXPO_PUBLIC_API_HOST=http://192.168.178.20:3000
EXPO_PUBLIC_FORMBRICKS_ENVIRONMENT_ID=clzr04nkd000bcdl110j0ijyq

View 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
View 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

View File

View 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"
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

View File

@@ -0,0 +1,6 @@
module.exports = function babel(api) {
api.cache(true);
return {
presets: ["babel-preset-expo"],
};
};

View File

@@ -0,0 +1,7 @@
import { registerRootComponent } from "expo";
import { LogBox } from "react-native";
import App from "./src/app";
registerRootComponent(App);
LogBox.ignoreAllLogs();

View 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;

View 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
}

View 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",
},
});

View File

@@ -0,0 +1,6 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true
}
}

View File

@@ -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}

View File

@@ -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:

View File

@@ -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. Lets 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. Lets go!
<Note>
App Surveys are ideal for websites that **have a user authentication** system. If you are looking to run surveys on your public facing website, head over to the [Website Surveys Quickstart Guide](/website-surveys/quickstart).
App Surveys are ideal for websites that **have a user authentication** system. If you are looking to run
surveys on your public facing website, head over to the [Website Surveys Quickstart
Guide](/website-surveys/quickstart).
</Note>
1. **Create a free Formbricks Cloud account**: While you can [self-host](/self-hosting/deployment) Formbricks, but the quickest and easiest way to get started is with the free Cloud plan. Just [sign up here](https://app.formbricks.com/auth/signup) and you'll be guided to our onboarding like below:
@@ -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, youll be asked to connect your app or website. This is where youll find the code snippet for both HTML as well as the npm package which you need to embed in your app:

View 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. Its 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>

View File

@@ -94,8 +94,8 @@ cp .env.example .env
4. Generate & set some secret values mandatory for the `ENCRYPTION_KEY`, `NEXTAUTH_SECRET` and `CRON_SECRET` in the .env file. You can use the following command to generate the random string of required length:
- For Linux
<Col>
<CodeGroup title="For Linux">
<Col>
<CodeGroup title="For Linux">
```bash
sed -i '/^ENCRYPTION_KEY=/c\ENCRYPTION_KEY='$(openssl rand -hex 32) .env
@@ -106,9 +106,9 @@ sed -i '/^CRON_SECRET=/c\CRON_SECRET='$(openssl rand -hex 32) .env
</CodeGroup>
</Col>
- For Mac
<Col>
<CodeGroup title="For Mac">
- For Mac
<Col>
<CodeGroup title="For Mac">
```bash
sed -i '' '/^ENCRYPTION_KEY=/s|.*|ENCRYPTION_KEY='$(openssl rand -hex 32)'|' .env

View File

@@ -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" },

View File

@@ -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);
}}
/>
)}

View File

@@ -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"

View File

@@ -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",

View File

@@ -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 });
}

View File

@@ -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);
}

View File

@@ -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,

View File

@@ -1,3 +1,4 @@
/* eslint-disable no-console -- used for error logging */
import type { TUploadFileConfig, TUploadFileResponse } from "@formbricks/types/storage";
export class StorageAPI {
@@ -10,11 +11,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 +62,35 @@ export class StorageAPI {
};
}
const formData = new FormData();
const formData: Record<string, string> = {};
if (presignedFields) {
Object.keys(presignedFields).forEach((key) => {
formData.append(key, presignedFields[key]);
formData[key] = presignedFields[key];
});
}
// 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: 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:

View File

@@ -6,6 +6,6 @@
"jsx": "react-jsx",
"lib": ["ES2015", "DOM"],
"module": "ESNext",
"target": "es6"
"target": "ES2022"
}
}

View 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"
}
}

View File

@@ -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);

View File

@@ -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

View File

@@ -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);
})();
}
}

View File

@@ -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();

View File

@@ -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");
};

View File

@@ -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.");

View File

@@ -8,14 +8,19 @@ import { TUploadFileConfig } from "@formbricks/types/storage";
import { TSurvey } from "@formbricks/types/surveys/types";
import { ErrorHandler } from "../../shared/errors";
import { Logger } from "../../shared/logger";
import { getDefaultLanguageCode, getLanguageCode, handleHiddenFields } from "../../shared/utils";
import {
getDefaultLanguageCode,
getLanguageCode,
handleHiddenFields,
shouldDisplayBasedOnPercentage,
} from "../../shared/utils";
import { AppConfig } from "./config";
import { putFormbricksInErrorState } from "./initialize";
import { sync } from "./sync";
const containerId = "formbricks-app-container";
const inAppConfig = AppConfig.getInstance();
const appConfig = AppConfig.getInstance();
const logger = Logger.getInstance();
const errorHandler = ErrorHandler.getInstance();
let isSurveyRunning = false;
@@ -26,11 +31,6 @@ export const setIsSurveyRunning = (value: boolean) => {
isSurveyRunning = value;
};
const shouldDisplayBasedOnPercentage = (displayPercentage: number) => {
const randomNum = Math.floor(Math.random() * 10000) / 100;
return randomNum <= displayPercentage;
};
export const triggerSurvey = async (
survey: TSurvey,
action?: string,
@@ -68,8 +68,8 @@ const renderWidget = async (
logger.debug(`Delaying survey "${survey.name}" by ${survey.delay} seconds.`);
}
const product = inAppConfig.get().state.product;
const attributes = inAppConfig.get().state.attributes;
const product = appConfig.get().state.product;
const attributes = appConfig.get().state.attributes;
const isMultiLanguageSurvey = survey.languages.length > 1;
let languageCode = "default";
@@ -85,12 +85,12 @@ const renderWidget = async (
languageCode = displayLanguage;
}
const surveyState = new SurveyState(survey.id, null, null, inAppConfig.get().userId);
const surveyState = new SurveyState(survey.id, null, null, appConfig.get().userId);
const responseQueue = new ResponseQueue(
{
apiHost: inAppConfig.get().apiHost,
environmentId: inAppConfig.get().environmentId,
apiHost: appConfig.get().apiHost,
environmentId: appConfig.get().environmentId,
retryAttempts: 2,
onResponseSendingFailed: () => {
setIsError(true);
@@ -124,11 +124,11 @@ const renderWidget = async (
setIsResponseSendingFinished = f;
},
onDisplay: async () => {
const { userId } = inAppConfig.get();
const { userId } = appConfig.get();
const api = new FormbricksAPI({
apiHost: inAppConfig.get().apiHost,
environmentId: inAppConfig.get().environmentId,
apiHost: appConfig.get().apiHost,
environmentId: appConfig.get().environmentId,
});
const res = await api.client.display.create({
@@ -146,7 +146,7 @@ const renderWidget = async (
responseQueue.updateSurveyState(surveyState);
},
onResponse: (responseUpdate: TResponseUpdate) => {
const { userId } = inAppConfig.get();
const { userId } = appConfig.get();
surveyState.updateUserId(userId);
responseQueue.updateSurveyState(surveyState);
@@ -164,13 +164,23 @@ const renderWidget = async (
});
},
onClose: closeSurvey,
onFileUpload: async (file: File, params: TUploadFileConfig) => {
onFileUpload: async (
file: { type: string; name: string; base64: string },
params: TUploadFileConfig
) => {
const api = new FormbricksAPI({
apiHost: inAppConfig.get().apiHost,
environmentId: inAppConfig.get().environmentId,
apiHost: appConfig.get().apiHost,
environmentId: appConfig.get().environmentId,
});
return await api.client.storage.uploadFile(file, params);
return await api.client.storage.uploadFile(
{
type: file.type,
name: file.name,
base64: file.base64,
},
params
);
},
onRetry: () => {
setIsError(false);
@@ -190,12 +200,13 @@ export const closeSurvey = async (): Promise<void> => {
try {
await sync(
{
apiHost: inAppConfig.get().apiHost,
environmentId: inAppConfig.get().environmentId,
userId: inAppConfig.get().userId,
attributes: inAppConfig.get().state.attributes,
apiHost: appConfig.get().apiHost,
environmentId: appConfig.get().environmentId,
userId: appConfig.get().userId,
attributes: appConfig.get().state.attributes,
},
true
true,
appConfig
);
setIsSurveyRunning(false);
} catch (e: any) {
@@ -220,7 +231,7 @@ const loadFormbricksSurveysExternally = (): Promise<typeof window.formbricksSurv
resolve(window.formbricksSurveys);
} else {
const script = document.createElement("script");
script.src = `${inAppConfig.get().apiHost}/api/packages/surveys`;
script.src = `${appConfig.get().apiHost}/api/packages/surveys`;
script.async = true;
script.onload = () => resolve(window.formbricksSurveys);
script.onerror = (error) => {

View File

@@ -1,8 +1,8 @@
import { wrapThrowsAsync } from "@formbricks/types/error-handlers";
import { TJsPackageType } from "@formbricks/types/js";
import { checkInitialized as checkInitializedInApp } from "../app/lib/initialize";
import { ErrorHandler, Result } from "../shared/errors";
import { checkInitialized as checkInitializedWebsite } from "../website/lib/initialize";
import { ErrorHandler, Result } from "./errors";
export class CommandQueue {
private queue: {

View File

@@ -0,0 +1,3 @@
export const APP_SURVEYS_LOCAL_STORAGE_KEY = "formbricks-js-app";
export const RN_ASYNC_STORAGE_KEY = "formbricks-react-native";
export const WEBSITE_SURVEYS_LOCAL_STORAGE_KEY = "formbricks-js-website";

View File

@@ -22,11 +22,17 @@ export const wrap =
(result: Result<T>): Result<R> =>
result.ok === true ? { ok: true, value: fn(result.value) } : result;
export const match = <TSuccess, TError, TReturn>(
export function match<TSuccess, TError, TReturn>(
result: Result<TSuccess, TError>,
onSuccess: (value: TSuccess) => TReturn,
onError: (error: TError) => TReturn
): TReturn => (result.ok === true ? onSuccess(result.value) : onError(result.error));
) {
if (result.ok === true) {
return onSuccess(result.value);
}
return onError(result.error);
}
/*
Usage:

View File

@@ -7,44 +7,10 @@ import { TAttributes } from "@formbricks/types/attributes";
import { TJsTrackProperties } from "@formbricks/types/js";
import { TResponseHiddenFieldValue } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { Logger } from "../shared/logger";
import { Logger } from "./logger";
const logger = Logger.getInstance();
export const getIsDebug = () => window.location.search.includes("formbricksDebug=true");
export const getLanguageCode = (survey: TSurvey, attributes: TAttributes): string | undefined => {
const language = attributes.language;
const availableLanguageCodes = survey.languages.map((surveyLanguage) => surveyLanguage.language.code);
if (!language) return "default";
else {
const selectedLanguage = survey.languages.find((surveyLanguage) => {
return (
surveyLanguage.language.code === language.toLowerCase() ||
surveyLanguage.language.alias?.toLowerCase() === language.toLowerCase()
);
});
if (selectedLanguage?.default) {
return "default";
}
if (
!selectedLanguage ||
!selectedLanguage?.enabled ||
!availableLanguageCodes.includes(selectedLanguage.language.code)
) {
return undefined;
}
return selectedLanguage.language.code;
}
};
export const getDefaultLanguageCode = (survey: TSurvey) => {
const defaultSurveyLanguage = survey.languages?.find((surveyLanguage) => {
return surveyLanguage.default === true;
});
if (defaultSurveyLanguage) return defaultSurveyLanguage.language.code;
};
export const checkUrlMatch = (
url: string,
pageUrlValue: string,
@@ -141,3 +107,41 @@ export const handleHiddenFields = (
return hiddenFieldsObject;
};
export const getIsDebug = () => window.location.search.includes("formbricksDebug=true");
export const shouldDisplayBasedOnPercentage = (displayPercentage: number) => {
const randomNum = Math.floor(Math.random() * 10000) / 100;
return randomNum <= displayPercentage;
};
export const getLanguageCode = (survey: TSurvey, attributes: TAttributes): string | undefined => {
const language = attributes.language;
const availableLanguageCodes = survey.languages.map((surveyLanguage) => surveyLanguage.language.code);
if (!language) return "default";
else {
const selectedLanguage = survey.languages.find((surveyLanguage) => {
return (
surveyLanguage.language.code === language.toLowerCase() ||
surveyLanguage.language.alias?.toLowerCase() === language.toLowerCase()
);
});
if (selectedLanguage?.default) {
return "default";
}
if (
!selectedLanguage ||
!selectedLanguage?.enabled ||
!availableLanguageCodes.includes(selectedLanguage.language.code)
) {
return undefined;
}
return selectedLanguage.language.code;
}
};
export const getDefaultLanguageCode = (survey: TSurvey) => {
const defaultSurveyLanguage = survey.languages?.find((surveyLanguage) => {
return surveyLanguage.default === true;
});
if (defaultSurveyLanguage) return defaultSurveyLanguage.language.code;
};

View File

@@ -1,8 +1,7 @@
import { TJsWebsiteConfig, TJsWebsiteConfigUpdateInput } from "@formbricks/types/js";
import { WEBSITE_SURVEYS_LOCAL_STORAGE_KEY } from "../../shared/constants";
import { Result, err, ok, wrapThrows } from "../../shared/errors";
export const WEBSITE_LOCAL_STORAGE_KEY = "formbricks-js-website";
export class WebsiteConfig {
private static instance: WebsiteConfig | undefined;
private config: TJsWebsiteConfig | null = null;
@@ -43,11 +42,8 @@ export class WebsiteConfig {
public loadFromLocalStorage(): Result<TJsWebsiteConfig, Error> {
if (typeof window !== "undefined") {
const savedConfig = localStorage.getItem(WEBSITE_LOCAL_STORAGE_KEY);
const savedConfig = localStorage.getItem(WEBSITE_SURVEYS_LOCAL_STORAGE_KEY);
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 TJsWebsiteConfig;
// check if the config has expired
@@ -63,7 +59,9 @@ export class WebsiteConfig {
}
private saveToLocalStorage(): Result<void, Error> {
return wrapThrows(() => localStorage.setItem(WEBSITE_LOCAL_STORAGE_KEY, JSON.stringify(this.config)))();
return wrapThrows(() =>
localStorage.setItem(WEBSITE_SURVEYS_LOCAL_STORAGE_KEY, JSON.stringify(this.config))
)();
}
// reset the config
@@ -71,6 +69,6 @@ export class WebsiteConfig {
public resetConfig(): Result<void, Error> {
this.config = null;
return wrapThrows(() => localStorage.removeItem(WEBSITE_LOCAL_STORAGE_KEY))();
return wrapThrows(() => localStorage.removeItem(WEBSITE_SURVEYS_LOCAL_STORAGE_KEY))();
}
}

View File

@@ -1,4 +1,5 @@
import type { TJSAppConfig, TJsWebsiteConfig, TJsWebsiteConfigInput } from "@formbricks/types/js";
import { WEBSITE_SURVEYS_LOCAL_STORAGE_KEY } from "../../shared/constants";
import {
ErrorHandler,
MissingFieldError,
@@ -13,7 +14,7 @@ import {
import { Logger } from "../../shared/logger";
import { getIsDebug } from "../../shared/utils";
import { trackNoCodeAction } from "./actions";
import { WEBSITE_LOCAL_STORAGE_KEY, WebsiteConfig } from "./config";
import { WebsiteConfig } from "./config";
import { addCleanupEventListeners, addEventListeners, removeAllEventListeners } from "./eventListeners";
import { checkPageUrl } from "./noCodeActions";
import { sync } from "./sync";
@@ -174,7 +175,9 @@ 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(WEBSITE_LOCAL_STORAGE_KEY, JSON.stringify(initialErrorConfig)))();
wrapThrows(() =>
localStorage.setItem(WEBSITE_SURVEYS_LOCAL_STORAGE_KEY, JSON.stringify(initialErrorConfig))
)();
throw new Error("Could not initialize formbricks");
};

View File

@@ -194,13 +194,23 @@ const renderWidget = async (
});
},
onClose: closeSurvey,
onFileUpload: async (file: File, params: TUploadFileConfig) => {
onFileUpload: async (
file: { type: string; name: string; base64: string },
params: TUploadFileConfig
) => {
const api = new FormbricksAPI({
apiHost: websiteConfig.get().apiHost,
environmentId: websiteConfig.get().environmentId,
});
return await api.client.storage.uploadFile(file, params);
return await api.client.storage.uploadFile(
{
name: file.name,
type: file.type,
base64: file.base64,
},
params
);
},
onRetry: () => {
setIsError(false);

View File

@@ -7,7 +7,7 @@
e.parentNode.insertBefore(t, e),
setTimeout(function () {
formbricks.init({
environmentId: "clzmroqa40005bbl9zmlzepy4",
environmentId: "cm020vmv0000cpq4xvxabpo8x",
userId: "RANDOM_USER_ID",
apiHost: "http://localhost:3000",
});

View File

@@ -0,0 +1,11 @@
module.exports = {
extends: ["@formbricks/eslint-config/react.js"],
parserOptions: {
project: "tsconfig.json",
tsconfigRootDir: __dirname,
},
rules: {
"@typescript-eslint/no-explicit-any": "off",
"import/no-relative-packages": "off",
},
};

28
packages/react-native/.gitignore vendored Normal file
View File

@@ -0,0 +1,28 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
node_modules
.pnp
.pnp.js
# misc
.DS_Store
*.pem
# build
dist
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env.local
.env.development.local
.env.test.local
.env.production.local
# turbo
.turbo

View File

@@ -0,0 +1,9 @@
MIT License
Copyright (c) 2024 Formbricks GmbH
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@@ -0,0 +1,47 @@
# Formbricks React Native SDK
[![npm package](https://img.shields.io/npm/v/@formbricks/react-native?style=flat-square)](https://www.npmjs.com/package/@formbricks/react-native)
[![MIT License](https://img.shields.io/badge/License-MIT-red.svg?style=flat-square)](https://opensource.org/licenses/MIT)
Please see [Formbricks Docs](https://formbricks.com/docs).
Specifically, [Framework Guides](https://formbricks.com/docs/getting-started/framework-guides).
## What is Formbricks
Formbricks is your go-to solution for in-product micro-surveys that will supercharge your product experience! 🚀 For more information please check out [formbricks.com](https://formbricks.com).
## How to use this library
1. Install the Formbricks package inside your project using npm:
```bash
npm install @formbricks/react-native
```
1. Import Formbricks and initialize the widget in your main component (e.g., App.tsx or App.js):
```javascript
import Formbricks, { track } from "@formbricks/react-native";
export default function App() {
const config = {
environmentId: "your-environment-id",
apiHost: "https://app.formbricks.com",
userId: "hello-user",
attributes: {
plan: "free",
},
};
return (
<View>
{/* Your app code */}
<Formbricks initConfig={config} />
</View>
);
}
```
Replace your-environment-id with your actual environment ID. You can find your environment ID in the **Connections instructions** in the Formbricks **Configuration** pages. Please make sure to pass a unique user identifier as `userId` to the Formbricks SDK (e.g. database id, email address).
For more detailed guides for different frameworks, check out our [Framework Guides](https://formbricks.com/docs/getting-started/framework-guides).

View File

@@ -0,0 +1,59 @@
{
"name": "@formbricks/react-native",
"version": "1.0.0-beta.4",
"license": "MIT",
"description": "Formbricks React Native SDK allows you to connect your app to Formbricks, display surveys and trigger events.",
"homepage": "https://formbricks.com",
"repository": {
"type": "git",
"url": "https://github.com/formbricks/formbricks"
},
"keywords": [
"Formbricks",
"surveys",
"experience management",
"react native",
"sdk"
],
"author": "Formbricks <hola@formbricks.com>",
"sideEffects": false,
"type": "module",
"main": "dist/index.cjs",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
"dist"
],
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
},
"scripts": {
"build": "tsc && vite build",
"build:dev": "tsc && vite build --mode dev",
"lint": "eslint src --ext .ts,.js,.tsx,.jsx",
"go": "vite build --watch --mode dev",
"clean": "rimraf .turbo node_modules dist .turbo"
},
"devDependencies": {
"@formbricks/api": "workspace:*",
"@formbricks/config-typescript": "workspace:*",
"@formbricks/lib": "workspace:*",
"@formbricks/types": "workspace:*",
"@react-native-async-storage/async-storage": "1.23.1",
"@types/react": "^18.2.79",
"react": "18.2.0",
"react-native": "^0.74.5",
"terser": "^5.31.3",
"vite": "^5.3.5",
"vite-plugin-dts": "^3.9.1"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-native": ">=0.60.0",
"react-native-webview": ">=13.0.0"
}
}

View File

@@ -0,0 +1,44 @@
import React, { useCallback, useEffect, useSyncExternalStore } from "react";
import { type TJsAppConfigInput } from "@formbricks/types/js";
import { Logger } from "../../js-core/src/shared/logger";
import { init } from "./lib";
import { SurveyStore } from "./lib/survey-store";
import { SurveyWebView } from "./survey-web-view";
interface FormbricksProps {
initConfig: TJsAppConfigInput;
}
const surveyStore = SurveyStore.getInstance();
const logger = Logger.getInstance();
export function Formbricks({ initConfig }: FormbricksProps): React.JSX.Element | null {
// initializes sdk
useEffect(() => {
const initialize = async (): Promise<void> => {
try {
await init({
environmentId: initConfig.environmentId,
apiHost: initConfig.apiHost,
userId: initConfig.userId,
attributes: initConfig.attributes,
});
} catch (error) {
logger.debug("Initialization failed");
}
};
initialize().catch(() => {
logger.debug("Initialization error");
});
}, [initConfig]);
const subscribe = useCallback((callback: () => void) => {
const unsubscribe = surveyStore.subscribe(callback);
return unsubscribe;
}, []);
const getSnapshot = useCallback(() => surveyStore.getSurvey(), []);
const survey = useSyncExternalStore(subscribe, getSnapshot);
return survey ? <SurveyWebView survey={survey} /> : null;
}

View File

@@ -0,0 +1,6 @@
import { Formbricks } from "./formbricks";
import { track } from "./lib";
export default Formbricks;
export { track };

View File

@@ -0,0 +1,70 @@
import { type TSurvey } from "@formbricks/types/surveys/types";
import {
type InvalidCodeError,
type NetworkError,
type Result,
err,
okVoid,
} from "../../../js-core/src/shared/errors";
import { Logger } from "../../../js-core/src/shared/logger";
import { shouldDisplayBasedOnPercentage } from "../../../js-core/src/shared/utils";
import { appConfig } from "./config";
import { SurveyStore } from "./survey-store";
const logger = Logger.getInstance();
const surveyStore = SurveyStore.getInstance();
export const triggerSurvey = (survey: TSurvey): void => {
// Check if the survey should be displayed based on displayPercentage
if (survey.displayPercentage) {
const shouldDisplaySurvey = shouldDisplayBasedOnPercentage(survey.displayPercentage);
if (!shouldDisplaySurvey) {
logger.debug(`Survey display of "${survey.name}" skipped based on displayPercentage.`);
return; // skip displaying the survey
}
}
surveyStore.setSurvey(survey);
};
export const trackAction = (name: string, alias?: string): Result<void, NetworkError> => {
const aliasName = alias ?? name;
logger.debug(`Formbricks: Action "${aliasName}" tracked`);
// get a list of surveys that are collecting insights
const activeSurveys = appConfig.get().state.surveys;
if (Boolean(activeSurveys) && activeSurveys.length > 0) {
for (const survey of activeSurveys) {
for (const trigger of survey.triggers) {
if (trigger.actionClass.name === name) {
triggerSurvey(survey);
}
}
}
} else {
logger.debug("No active surveys to display");
}
return okVoid();
};
export const trackCodeAction = (
code: string
): Result<void, NetworkError> | Result<void, InvalidCodeError> => {
const {
state: { actionClasses = [] },
} = appConfig.get();
const codeActionClasses = actionClasses.filter((action) => action.type === "code");
const actionClass = codeActionClasses.find((action) => action.key === code);
if (!actionClass) {
return err({
code: "invalid_code",
message: `${code} action unknown. Please add this action in Formbricks first in order to use it in your code.`,
});
}
return trackAction(actionClass.name, code);
};

View File

@@ -0,0 +1,77 @@
import { wrapThrowsAsync } from "@formbricks/types/error-handlers";
import { type TJsPackageType } from "@formbricks/types/js";
import { ErrorHandler, type Result } from "../../../js-core/src/shared/errors";
import { checkInitialized } from "./initialize";
export class CommandQueue {
private queue: {
command: (...args: any[]) => Promise<Result<void, unknown>> | Result<void, unknown> | Promise<void>;
packageType: TJsPackageType;
checkInitialized: boolean;
commandArgs: any[];
}[] = [];
private running = false;
private resolvePromise: (() => void) | null = null;
private commandPromise: Promise<void> | null = null;
public add<A>(
packageType: TJsPackageType,
command: (...args: A[]) => Promise<Result<void, unknown>> | Result<void, unknown> | Promise<void>,
shouldCheckInitialized = true,
...args: A[]
): void {
this.queue.push({ command, checkInitialized: shouldCheckInitialized, commandArgs: args, packageType });
if (!this.running) {
this.commandPromise = new Promise((resolve) => {
this.resolvePromise = resolve;
void this.run();
});
}
}
public async wait(): Promise<void> {
if (this.running) {
await this.commandPromise;
}
}
private async run(): Promise<void> {
this.running = true;
while (this.queue.length > 0) {
const errorHandler = ErrorHandler.getInstance();
const currentItem = this.queue.shift();
if (!currentItem) continue;
// make sure formbricks is initialized
if (currentItem.checkInitialized) {
// call different function based on package type
const initResult = checkInitialized();
if (!initResult.ok) {
errorHandler.handle(initResult.error);
continue;
}
}
const executeCommand = async (): Promise<Result<void, unknown>> => {
return (await currentItem.command.apply(null, currentItem.commandArgs)) as Result<void, unknown>;
};
const result = await wrapThrowsAsync(executeCommand)();
if (!result.ok) {
errorHandler.handle(result.error);
} else if (!result.data.ok) {
errorHandler.handle(result.data.error);
}
}
this.running = false;
if (this.resolvePromise) {
this.resolvePromise();
this.resolvePromise = null;
this.commandPromise = null;
}
}
}

View File

@@ -0,0 +1,11 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import { AppConfig, type StorageHandler } from "../../../js-core/src/app/lib/config";
import { RN_ASYNC_STORAGE_KEY } from "../../../js-core/src/shared/constants";
const storageHandler: StorageHandler = {
getItem: async (key: string) => AsyncStorage.getItem(key),
setItem: async (key: string, value: string) => AsyncStorage.setItem(key, value),
removeItem: async (key: string) => AsyncStorage.removeItem(key),
};
export const appConfig = AppConfig.getInstance(storageHandler, RN_ASYNC_STORAGE_KEY);

View File

@@ -0,0 +1,21 @@
import { type TJsAppConfigInput } from "@formbricks/types/js";
import { ErrorHandler } from "../../../js-core/src/shared/errors";
import { Logger } from "../../../js-core/src/shared/logger";
import { trackCodeAction } from "./actions";
import { CommandQueue } from "./command-queue";
import { initialize } from "./initialize";
const logger = Logger.getInstance();
logger.debug("Create command queue");
const queue = new CommandQueue();
export const init = async (initConfig: TJsAppConfigInput): Promise<void> => {
ErrorHandler.init(initConfig.errorHandler);
queue.add("app", initialize, false, initConfig);
await queue.wait();
};
export const track = async (name: string, properties = {}): Promise<void> => {
queue.add<any>("app", trackCodeAction, true, name, properties);
await queue.wait();
};

View File

@@ -0,0 +1,154 @@
import { type TAttributes } from "@formbricks/types/attributes";
import { type TJSAppConfig, type TJsAppConfigInput } from "@formbricks/types/js";
import { updateAttributes } from "../../../js-core/src/app/lib/attributes";
import { sync } from "../../../js-core/src/app/lib/sync";
import {
ErrorHandler,
type MissingFieldError,
type MissingPersonError,
type NetworkError,
type NotInitializedError,
type Result,
err,
okVoid,
} from "../../../js-core/src/shared/errors";
import { Logger } from "../../../js-core/src/shared/logger";
import { trackAction } from "./actions";
import { appConfig } from "./config";
let isInitialized = false;
const logger = Logger.getInstance();
export const setIsInitialize = (state: boolean): void => {
isInitialized = state;
};
export const initialize = async (
c: TJsAppConfigInput
): Promise<Result<void, MissingFieldError | NetworkError | MissingPersonError>> => {
if (isInitialized) {
logger.debug("Already initialized, skipping initialization.");
return okVoid();
}
ErrorHandler.getInstance().printStatus();
logger.debug("Start initialize");
if (!c.environmentId) {
logger.debug("No environmentId provided");
return err({
code: "missing_field",
field: "environmentId",
});
}
if (!c.apiHost) {
logger.debug("No apiHost provided");
return err({
code: "missing_field",
field: "apiHost",
});
}
// if userId and attributes are available, set them in backend
let updatedAttributes: TAttributes | null = null;
if (c.userId && c.attributes) {
const res = await updateAttributes(c.apiHost, c.environmentId, c.userId, c.attributes, appConfig);
if (!res.ok) {
return err(res.error);
}
updatedAttributes = res.value;
}
let existingConfig: TJSAppConfig | undefined;
try {
existingConfig = appConfig.get();
} catch (e) {
logger.debug("No existing configuration found.");
}
if (
existingConfig?.state &&
existingConfig.environmentId === c.environmentId &&
existingConfig.apiHost === c.apiHost &&
existingConfig.userId === c.userId &&
Boolean(existingConfig.expiresAt) // only accept config when they follow new config version with expiresAt
) {
logger.debug("Found existing configuration.");
if (existingConfig.expiresAt < new Date()) {
logger.debug("Configuration expired.");
await sync(
{
apiHost: c.apiHost,
environmentId: c.environmentId,
userId: c.userId,
},
true,
appConfig
);
} else {
logger.debug("Configuration not expired. Extending expiration.");
appConfig.update(existingConfig);
}
} else {
logger.debug("No valid configuration found or it has been expired. Creating new config.");
logger.debug("Syncing.");
await sync(
{
apiHost: c.apiHost,
environmentId: c.environmentId,
userId: c.userId,
},
true,
appConfig
);
// and track the new session event
trackAction("New Session");
}
// todo: update attributes
// update attributes in config
if (updatedAttributes && Object.keys(updatedAttributes).length > 0) {
appConfig.update({
environmentId: appConfig.get().environmentId,
apiHost: appConfig.get().apiHost,
userId: appConfig.get().userId,
state: {
...appConfig.get().state,
attributes: { ...appConfig.get().state.attributes, ...c.attributes },
},
expiresAt: appConfig.get().expiresAt,
});
}
setIsInitialize(true);
logger.debug("Initialized");
return okVoid();
};
export const checkInitialized = (): Result<void, NotInitializedError> => {
logger.debug("Check if initialized");
if (!isInitialized || !ErrorHandler.initialized) {
return err({
code: "not_initialized",
message: "Formbricks not initialized. Call initialize() first.",
});
}
return okVoid();
};
export const deinitalize = async (): Promise<void> => {
logger.debug("Deinitializing");
// closeSurvey();
await appConfig.resetConfig();
setIsInitialize(false);
};

View File

@@ -0,0 +1,28 @@
import { type NetworkError, type Result, err, okVoid } from "../../../js-core/src/shared/errors";
import { Logger } from "../../../js-core/src/shared/logger";
import { appConfig } from "./config";
import { deinitalize, initialize } from "./initialize";
const logger = Logger.getInstance();
export const logoutPerson = async (): Promise<void> => {
await deinitalize();
await appConfig.resetConfig();
};
export const resetPerson = async (): Promise<Result<void, NetworkError>> => {
logger.debug("Resetting state & getting new state from backend");
const syncParams = {
environmentId: appConfig.get().environmentId,
apiHost: appConfig.get().apiHost,
userId: appConfig.get().userId,
attributes: appConfig.get().state.attributes,
};
await logoutPerson();
try {
await initialize(syncParams);
return okVoid();
} catch (e) {
return err(e as NetworkError);
}
};

View File

@@ -0,0 +1,46 @@
import { type TSurvey } from "@formbricks/types/surveys/types";
type Listener = (state: TSurvey | null, prevSurvey: TSurvey | null) => void;
export class SurveyStore {
private static instance: SurveyStore | undefined;
private survey: TSurvey | null = null;
private listeners = new Set<Listener>();
static getInstance(): SurveyStore {
if (!SurveyStore.instance) {
SurveyStore.instance = new SurveyStore();
}
return SurveyStore.instance;
}
public getSurvey(): TSurvey | null {
return this.survey;
}
public setSurvey(survey: TSurvey): void {
const prevSurvey = this.survey;
if (prevSurvey !== survey) {
this.survey = survey;
this.listeners.forEach((listener) => {
listener(this.survey, prevSurvey);
});
}
}
public resetSurvey(): void {
const prevSurvey = this.survey;
if (prevSurvey !== null) {
this.survey = null;
this.listeners.forEach((listener) => {
listener(this.survey, prevSurvey);
});
}
}
public subscribe(listener: Listener) {
this.listeners.add(listener);
// Unsubscribe
return () => this.listeners.delete(listener);
}
}

View File

@@ -0,0 +1,389 @@
/* eslint-disable @typescript-eslint/no-unsafe-call -- required */
/* eslint-disable no-console -- debugging*/
import React, { useEffect, useMemo, useRef, useState } from "react";
import { Modal } from "react-native";
import { WebView, type WebViewMessageEvent } from "react-native-webview";
import { FormbricksAPI } from "@formbricks/api";
import { ResponseQueue } from "@formbricks/lib/responseQueue";
import { SurveyState } from "@formbricks/lib/surveyState";
import { getStyling } from "@formbricks/lib/utils/styling";
import type { SurveyInlineProps } from "@formbricks/types/formbricks-surveys";
import { ZJsRNWebViewOnMessageData } from "@formbricks/types/js";
import type { TJsFileUploadParams } from "@formbricks/types/js";
import type { TResponseUpdate } from "@formbricks/types/responses";
import type { TUploadFileConfig } from "@formbricks/types/storage";
import type { TSurvey } from "@formbricks/types/surveys/types";
import { sync } from "../../js-core/src/app/lib/sync";
import { Logger } from "../../js-core/src/shared/logger";
import { getDefaultLanguageCode, getLanguageCode } from "../../js-core/src/shared/utils";
import { appConfig } from "./lib/config";
import { SurveyStore } from "./lib/survey-store";
const logger = Logger.getInstance();
logger.configure({ logLevel: "debug" });
const surveyStore = SurveyStore.getInstance();
let isSurveyRunning = false;
export const setIsSurveyRunning = (value: boolean): void => {
isSurveyRunning = value;
};
interface SurveyWebViewProps {
survey: TSurvey;
}
export function SurveyWebView({ survey }: SurveyWebViewProps): JSX.Element | undefined {
const webViewRef = useRef(null);
const [showSurvey, setShowSurvey] = useState(false);
const product = appConfig.get().state.product;
const attributes = appConfig.get().state.attributes;
const styling = getStyling(product, survey);
const isBrandingEnabled = product.inAppSurveyBranding;
const isMultiLanguageSurvey = survey.languages.length > 1;
const [surveyState, setSurveyState] = useState(
new SurveyState(survey.id, null, null, appConfig.get().userId)
);
const responseQueue = useMemo(
() =>
new ResponseQueue(
{
apiHost: appConfig.get().apiHost,
environmentId: appConfig.get().environmentId,
retryAttempts: 2,
setSurveyState,
},
surveyState
),
[surveyState]
);
useEffect(() => {
if (survey.delay) {
setTimeout(() => {
setShowSurvey(true);
}, survey.delay * 1000);
return;
}
setShowSurvey(true);
}, [survey.delay]);
let languageCode = "default";
if (isMultiLanguageSurvey) {
const displayLanguage = getLanguageCode(survey, attributes);
//if survey is not available in selected language, survey wont be shown
if (!displayLanguage) {
logger.debug(`Survey "${survey.name}" is not available in specified language.`);
setIsSurveyRunning(true);
return;
}
languageCode = displayLanguage;
}
const addResponseToQueue = (responseUpdate: TResponseUpdate): void => {
const { userId } = appConfig.get();
if (userId) surveyState.updateUserId(userId);
responseQueue.updateSurveyState(surveyState);
responseQueue.add({
data: responseUpdate.data,
ttc: responseUpdate.ttc,
finished: responseUpdate.finished,
language:
responseUpdate.language === "default" ? getDefaultLanguageCode(survey) : responseUpdate.language,
});
};
const onCloseSurvey = async (): Promise<void> => {
await sync(
{
apiHost: appConfig.get().apiHost,
environmentId: appConfig.get().environmentId,
userId: appConfig.get().userId,
},
false,
appConfig
);
surveyStore.resetSurvey();
setShowSurvey(false);
};
const createDisplay = async (surveyId: string): Promise<{ id: string }> => {
const { userId } = appConfig.get();
const api = new FormbricksAPI({
apiHost: appConfig.get().apiHost,
environmentId: appConfig.get().environmentId,
});
const res = await api.client.display.create({
surveyId,
userId,
});
if (!res.ok) {
throw new Error("Could not create display");
}
return res.data;
};
const uploadFile = async (
file: TJsFileUploadParams["file"],
params?: TUploadFileConfig
): Promise<string> => {
const api = new FormbricksAPI({
apiHost: appConfig.get().apiHost,
environmentId: appConfig.get().environmentId,
});
return await api.client.storage.uploadFile(file, params);
};
return (
<Modal
animationType="slide"
visible={showSurvey ? !isSurveyRunning : undefined}
transparent
onRequestClose={() => {
setShowSurvey(false);
}}>
<WebView
ref={webViewRef}
originWhitelist={["*"]}
source={{
html: renderHtml({
survey,
isBrandingEnabled,
styling,
languageCode,
apiHost: appConfig.get().apiHost,
}),
}}
style={{ backgroundColor: "transparent" }}
contentMode="mobile"
javaScriptEnabled
domStorageEnabled
startInLoadingState
mixedContentMode="always"
allowFileAccess
allowFileAccessFromFileURLs
allowUniversalAccessFromFileURLs
onShouldStartLoadWithRequest={(event) => {
// prevent webview from redirecting if users taps on formbricks link.
if (event.url.startsWith("https://formbricks")) {
return false;
}
return true;
}}
onMessage={async (event: WebViewMessageEvent) => {
try {
const { data } = event.nativeEvent;
const unvalidatedMessage = JSON.parse(data) as { type: string; data: unknown };
// debugger
if (unvalidatedMessage.type === "Console") {
console.info(`[Console] ${JSON.stringify(unvalidatedMessage.data)}`);
}
const validatedMessage = ZJsRNWebViewOnMessageData.safeParse(unvalidatedMessage);
if (!validatedMessage.success) {
logger.error("Error parsing message from WebView.");
return;
}
// display
const {
onDisplay,
onResponse,
responseUpdate,
onClose,
onRetry,
onFinished,
onFileUpload,
fileUploadParams,
uploadId,
} = validatedMessage.data;
if (onDisplay) {
const { id } = await createDisplay(survey.id);
surveyState.updateDisplayId(id);
}
if (onResponse && responseUpdate) {
addResponseToQueue(responseUpdate);
}
if (onClose) {
await onCloseSurvey();
}
if (onRetry) {
await responseQueue.processQueue();
}
if (onFinished) {
setTimeout(() => {
void (async () => {
await onCloseSurvey();
})();
}, 2500);
}
if (onFileUpload && fileUploadParams) {
const fileType = fileUploadParams.file.type;
const fileName = fileUploadParams.file.name;
const fileDataUri = fileUploadParams.file.base64;
if (fileDataUri) {
const file: TJsFileUploadParams["file"] = {
// uri: Platform.OS === "android" ? `data:${fileType};base64,${base64Data}` : base64Data,
base64: fileUploadParams.file.base64,
type: fileType,
name: fileName,
};
try {
const fileUploadResult = await uploadFile(file, fileUploadParams.params);
if (fileUploadResult) {
// @ts-expect-error -- injectJavaScript is not typed
webViewRef.current?.injectJavaScript(`
window.onFileUploadComplete(${JSON.stringify({
success: true,
url: fileUploadResult,
uploadId,
})});
`);
} else {
// @ts-expect-error -- injectJavaScript is not typed
webViewRef.current?.injectJavaScript(`
window.onFileUploadComplete(${JSON.stringify({
success: false,
error: "File upload failed",
uploadId,
})});
`);
}
} catch (error) {
console.error("Error in file upload: ", error);
}
}
}
} catch (error) {
logger.error(`Error handling WebView message: ${error as string}`);
}
}}
/>
</Modal>
);
}
const renderHtml = (options: Partial<SurveyInlineProps> & { apiHost?: string }): string => {
return `
<!doctype html>
<html>
<meta name="viewport" content="initial-scale=1.0, maximum-scale=1.0">
<head>
<title>Formbricks WebView Survey</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body style="overflow: hidden; height: 100vh; display: flex; flex-direction: column; justify-content: flex-end;">
<div id="formbricks-react-native" style="width: 100%;"></div>
</body>
<script type="text/javascript">
const consoleLog = (type, log) => window.ReactNativeWebView.postMessage(JSON.stringify({'type': 'Console', 'data': {'type': type, 'log': log}}));
console = {
log: (log) => consoleLog('log', log),
debug: (log) => consoleLog('debug', log),
info: (log) => consoleLog('info', log),
warn: (log) => consoleLog('warn', log),
error: (log) => consoleLog('error', log),
};
function onClose() {
window.ReactNativeWebView.postMessage(JSON.stringify({ onClose: true }));
};
function onFinished() {
window.ReactNativeWebView.postMessage(JSON.stringify({ onFinished: true }));
};
function onDisplay() {
window.ReactNativeWebView.postMessage(JSON.stringify({ onDisplay: true }));
};
function onResponse(responseUpdate) {
console.log(JSON.stringify({ onResponse: true, responseUpdate }));
window.ReactNativeWebView.postMessage(JSON.stringify({ onResponse: true, responseUpdate }));
};
function onRetry(responseUpdate) {
window.ReactNativeWebView.postMessage(JSON.stringify({ onRetry: true }));
};
window.fileUploadPromiseCallbacks = new Map();
function onFileUpload(file, params) {
return new Promise((resolve, reject) => {
const uploadId = Date.now() + '-' + Math.random(); // Generate a unique ID for this upload
window.ReactNativeWebView.postMessage(JSON.stringify({ onFileUpload: true, uploadId, fileUploadParams: { file, params } }));
const promiseResolve = (url) => {
resolve(url);
}
const promiseReject = (error) => {
reject(error);
}
window.fileUploadPromiseCallbacks.set(uploadId, { resolve: promiseResolve, reject: promiseReject });
});
}
// Add this function to handle the upload completion
function onFileUploadComplete(result) {
if (window.fileUploadPromiseCallbacks && window.fileUploadPromiseCallbacks.has(result.uploadId)) {
const callback = window.fileUploadPromiseCallbacks.get(result.uploadId);
if (result.success) {
callback.resolve(result.url);
} else {
callback.reject(new Error(result.error));
}
// Remove this specific callback
window.fileUploadPromiseCallbacks.delete(result.uploadId);
}
}
function loadSurvey() {
const options = ${JSON.stringify(options)};
const containerId = "formbricks-react-native";
const surveyProps = {
...options,
containerId,
onFinished,
onDisplay,
onResponse,
onRetry,
onClose,
onFileUpload
};
window.formbricksSurveys.renderSurveyInline(surveyProps);
}
const script = document.createElement("script");
script.src = "${options.apiHost ?? "http://localhost:3000"}/api/packages/surveys";
script.async = true;
script.onload = () => loadSurvey();
script.onerror = (error) => {
console.error("Failed to load Formbricks Surveys library:", error);
};
document.head.appendChild(script);
</script>
</html>
`;
};

View File

@@ -0,0 +1,8 @@
{
"extends": "@formbricks/config-typescript/react-native-library.json",
"include": ["."],
"exclude": ["dist", "build", "node_modules"],
"compilerOptions": {
"strict": true
}
}

View File

@@ -0,0 +1,28 @@
import { resolve } from "node:path";
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
const config = () => {
return defineConfig({
optimizeDeps: {
exclude: ["react-native"],
},
build: {
emptyOutDir: false,
minify: "terser",
sourcemap: true,
rollupOptions: {
external: ["react", "react-native", "react-dom", "react-native-webview"],
},
lib: {
entry: resolve(__dirname, "src/index.ts"),
name: "formbricksReactNative",
formats: ["es", "cjs"],
fileName: "index",
},
},
plugins: [dts({ rollupTypes: true, bundledPackages: ["@formbricks/api", "@formbricks/types"] })],
});
};
export default config;

View File

@@ -3,13 +3,14 @@ import { JSXInternal } from "preact/src/jsx";
import { getOriginalFileNameFromUrl } from "@formbricks/lib/storage/utils";
import { isFulfilled, isRejected } from "@formbricks/lib/utils/promises";
import { TAllowedFileExtension } from "@formbricks/types/common";
import { TJsFileUploadParams } from "@formbricks/types/js";
import { TUploadFileConfig } from "@formbricks/types/storage";
interface FileInputProps {
allowedFileExtensions?: TAllowedFileExtension[];
surveyId: string | undefined;
onUploadCallback: (uploadedUrls: string[]) => void;
onFileUpload: (file: File, config?: TUploadFileConfig) => Promise<string>;
onFileUpload: (file: TJsFileUploadParams["file"], config?: TUploadFileConfig) => Promise<string>;
fileUrls: string[] | undefined;
maxSizeInMB?: number;
allowMultipleFiles?: boolean;
@@ -21,8 +22,8 @@ const FILE_LIMIT = 25;
export const FileInput = ({
allowedFileExtensions,
surveyId,
onUploadCallback,
onFileUpload,
onUploadCallback,
fileUrls,
maxSizeInMB,
allowMultipleFiles,
@@ -30,7 +31,6 @@ export const FileInput = ({
}: FileInputProps) => {
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
const [isUploading, setIsUploading] = useState(false);
const validateFileSize = async (file: File): Promise<boolean> => {
if (maxSizeInMB) {
const fileBuffer = await file.arrayBuffer();
@@ -43,22 +43,57 @@ export const FileInput = ({
return true;
};
const handleFileUpload = async (files: File[]) => {
setIsUploading(true);
const handleFileSelection = async (files: FileList) => {
const fileArray = Array.from(files);
const validFiles = await Promise.all(
files.map(async (file) => {
if (await validateFileSize(file)) return file;
return null;
})
);
if (!allowMultipleFiles && fileArray.length > 1) {
alert("Only one file can be uploaded at a time.");
return;
}
const filteredFiles = validFiles.filter((file) => file !== null) as File[];
if (allowMultipleFiles && selectedFiles.length + fileArray.length > FILE_LIMIT) {
alert(`You can only upload a maximum of ${FILE_LIMIT} files.`);
return;
}
// filter out files that are not allowed
const validFiles = Array.from(files).filter((file) => {
const fileExtension = file.type.substring(file.type.lastIndexOf("/") + 1) as TAllowedFileExtension;
if (allowedFileExtensions) {
return allowedFileExtensions?.includes(fileExtension);
} else {
return true;
}
});
const filteredFiles: File[] = [];
for (const validFile of validFiles) {
const isAllowed = await validateFileSize(validFile);
if (isAllowed) {
filteredFiles.push(validFile);
}
}
try {
const uploadPromises = filteredFiles.map((file) =>
onFileUpload(file, { allowedFileExtensions, surveyId })
);
setIsUploading(true);
const toBase64 = (file: File) =>
new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => resolve(reader.result);
reader.onerror = reject;
});
const filePromises = filteredFiles.map(async (file) => {
const base64 = await toBase64(file);
return { name: file.name, type: file.type, base64: base64 as string };
});
const filesToUpload = await Promise.all(filePromises);
const uploadPromises = filesToUpload.map((file) => {
return onFileUpload(file, { allowedFileExtensions, surveyId });
});
const uploadedFiles = await Promise.allSettled(uploadPromises);
@@ -74,40 +109,13 @@ export const FileInput = ({
}
}
} catch (err: any) {
console.error("error in uploading file: ", err);
alert("Upload failed! Please try again.");
} finally {
setIsUploading(false);
}
};
const handleFileSelection = (files: FileList) => {
const fileArray = Array.from(files);
if (!allowMultipleFiles && fileArray.length > 1) {
alert("Only one file can be uploaded at a time.");
return;
}
if (allowMultipleFiles && selectedFiles.length + fileArray.length > FILE_LIMIT) {
alert(`You can only upload a maximum of ${FILE_LIMIT} files.`);
return;
}
const validFiles = fileArray.filter((file) =>
allowedFileExtensions?.length
? allowedFileExtensions.includes(
file.type.substring(file.type.lastIndexOf("/") + 1) as TAllowedFileExtension
)
: true
);
if (validFiles.length > 0) {
handleFileUpload(validFiles);
} else {
alert("No selected files are valid");
}
};
const handleDragOver = (e: JSXInternal.TargetedDragEvent<HTMLLabelElement>) => {
e.preventDefault();
e.stopPropagation();
@@ -149,8 +157,8 @@ export const FileInput = ({
<div
className={`fb-items-left fb-bg-input-bg hover:fb-bg-input-bg-selected fb-border-border fb-relative fb-mt-3 fb-flex fb-w-full fb-flex-col fb-justify-center fb-rounded-lg fb-border-2 fb-border-dashed dark:fb-border-slate-600 dark:fb-bg-slate-700 dark:hover:fb-border-slate-500 dark:hover:fb-bg-slate-800`}>
<div>
{fileUrls?.map((file, index) => {
const fileName = getOriginalFileNameFromUrl(file);
{fileUrls?.map((fileUrl, index) => {
const fileName = getOriginalFileNameFromUrl(fileUrl);
return (
<div
key={index}
@@ -237,7 +245,7 @@ export const FileInput = ({
name={uniqueHtmlFor}
accept={allowedFileExtensions?.map((ext) => `.${ext}`).join(",")}
className="fb-hidden"
onChange={(e) => {
onChange={async (e) => {
const inputElement = e.target as HTMLInputElement;
if (inputElement.files) {
handleFileSelection(inputElement.files);

View File

@@ -11,6 +11,7 @@ import { NPSQuestion } from "@/components/questions/NPSQuestion";
import { OpenTextQuestion } from "@/components/questions/OpenTextQuestion";
import { PictureSelectionQuestion } from "@/components/questions/PictureSelectionQuestion";
import { RatingQuestion } from "@/components/questions/RatingQuestion";
import { TJsFileUploadParams } from "@formbricks/types/js";
import { TResponseData, TResponseDataValue, TResponseTtc } from "@formbricks/types/responses";
import { TUploadFileConfig } from "@formbricks/types/storage";
import { TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
@@ -21,7 +22,7 @@ interface QuestionConditionalProps {
onChange: (responseData: TResponseData) => void;
onSubmit: (data: TResponseData, ttc: TResponseTtc) => void;
onBack: () => void;
onFileUpload: (file: File, config?: TUploadFileConfig) => Promise<string>;
onFileUpload: (file: TJsFileUploadParams["file"], config?: TUploadFileConfig) => Promise<string>;
isFirstQuestion: boolean;
isLastQuestion: boolean;
languageCode: string;

View File

@@ -5,6 +5,7 @@ import { ScrollableContainer } from "@/components/wrappers/ScrollableContainer";
import { getUpdatedTtc, useTtc } from "@/lib/ttc";
import { useState } from "preact/hooks";
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { TJsFileUploadParams } from "@formbricks/types/js";
import { TResponseData, TResponseTtc } from "@formbricks/types/responses";
import { TUploadFileConfig } from "@formbricks/types/storage";
import type { TSurveyFileUploadQuestion } from "@formbricks/types/surveys/types";
@@ -18,7 +19,7 @@ interface FileUploadQuestionProps {
onChange: (responseData: TResponseData) => void;
onSubmit: (data: TResponseData, ttc: TResponseTtc) => void;
onBack: () => void;
onFileUpload: (file: File, config?: TUploadFileConfig) => Promise<string>;
onFileUpload: (file: TJsFileUploadParams["file"], config?: TUploadFileConfig) => Promise<string>;
isFirstQuestion: boolean;
isLastQuestion: boolean;
surveyId: string;

View File

@@ -1,6 +1,7 @@
import { type TProductStyling } from "./product";
import { type TResponseData, type TResponseUpdate } from "./responses";
import { type TUploadFileConfig } from "./storage";
import type { TJsFileUploadParams } from "./js";
import type { TProductStyling } from "./product";
import type { TResponseData, TResponseUpdate } from "./responses";
import type { TUploadFileConfig } from "./storage";
import type { TSurvey, TSurveyStyling } from "./surveys/types";
export interface SurveyBaseProps {
@@ -20,7 +21,7 @@ export interface SurveyBaseProps {
prefillResponseData?: TResponseData;
skipPrefilled?: boolean;
languageCode: string;
onFileUpload: (file: File, config?: TUploadFileConfig) => Promise<string>;
onFileUpload: (file: TJsFileUploadParams["file"], config?: TUploadFileConfig) => Promise<string>;
responseCount?: number;
isCardBorderVisible?: boolean;
startAtQuestionId?: string;

View File

@@ -2,7 +2,8 @@ import { z } from "zod";
import { ZActionClass } from "./action-classes";
import { ZAttributes } from "./attributes";
import { ZProduct } from "./product";
import { ZResponseHiddenFieldValue } from "./responses";
import { ZResponseHiddenFieldValue, ZResponseUpdate } from "./responses";
import { ZUploadFileConfig } from "./storage";
import { ZSurvey } from "./surveys/types";
export const ZJsPerson = z.object({
@@ -186,3 +187,22 @@ export const ZJsTrackProperties = z.object({
});
export type TJsTrackProperties = z.infer<typeof ZJsTrackProperties>;
export const ZJsFileUploadParams = z.object({
file: z.object({ type: z.string(), name: z.string(), base64: z.string() }),
params: ZUploadFileConfig,
});
export type TJsFileUploadParams = z.infer<typeof ZJsFileUploadParams>;
export const ZJsRNWebViewOnMessageData = z.object({
onFinished: z.boolean().nullish(),
onDisplay: z.boolean().nullish(),
onResponse: z.boolean().nullish(),
responseUpdate: ZResponseUpdate.nullish(),
onRetry: z.boolean().nullish(),
onClose: z.boolean().nullish(),
onFileUpload: z.boolean().nullish(),
fileUploadParams: ZJsFileUploadParams.nullish(),
uploadId: z.string().nullish(),
});

View File

@@ -1,7 +1,7 @@
import { z } from "zod";
import { ZActionClass, ZActionClassNoCodeConfig } from "../action-classes";
import { ZAttributes } from "../attributes";
import { ZAllowedFileExtension, ZColor, ZPlacement , ZId } from "../common";
import { ZAllowedFileExtension, ZColor, ZId, ZPlacement } from "../common";
import { ZLanguage } from "../product";
import { ZSegment } from "../segment";
import { ZBaseStyling } from "../styling";

View File

@@ -4,6 +4,7 @@ import { Variants, motion } from "framer-motion";
import { ExpandIcon, MonitorIcon, ShrinkIcon, SmartphoneIcon } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { TEnvironment } from "@formbricks/types/environment";
import { TJsFileUploadParams } from "@formbricks/types/js";
import type { TProduct } from "@formbricks/types/product";
import { TProductStyling } from "@formbricks/types/product";
import { TUploadFileConfig } from "@formbricks/types/storage";
@@ -24,7 +25,7 @@ interface PreviewSurveyProps {
product: TProduct;
environment: TEnvironment;
languageCode: string;
onFileUpload: (file: File, config?: TUploadFileConfig) => Promise<string>;
onFileUpload: (file: TJsFileUploadParams["file"], config?: TUploadFileConfig) => Promise<string>;
}
let surveyNameTemp: string;

View File

@@ -59,7 +59,7 @@ export const SurveyInline = (props: Omit<SurveyInlineProps, "containerId">) => {
if (isScriptLoaded) {
renderInline();
}
}, [isScriptLoaded]);
}, [isScriptLoaded, renderInline]);
return <div id={containerId} className="h-full w-full" />;
};

5313
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -47,12 +47,21 @@
"persistent": true,
"dependsOn": ["@formbricks/api#build"]
},
"@formbricks/react-native#build": {
"outputs": ["dist/**"],
"dependsOn": ["^build"]
},
"@formbricks/react-native#go": {
"cache": false,
"persistent": true,
"dependsOn": ["@formbricks/database#db:setup", "@formbricks/api#build", "@formbricks/js#build"]
},
"@formbricks/react-native#lint": {
"dependsOn": ["@formbricks/api#build"]
},
"@formbricks/js#lint": {
"dependsOn": ["@formbricks/js-core#build"]
},
"@formbricks/database#lint": {
"dependsOn": ["@formbricks/database#build"]
},
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**"],