solve merge conflicts

This commit is contained in:
Matthias Nannt
2024-08-29 17:27:20 +02:00
211 changed files with 7893 additions and 1216 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

@@ -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 &apos;Reset&apos; and
try again.

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

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -37,4 +37,5 @@ export const minimalSurvey: TSurvey = {
languages: [],
showLanguageSwitch: false,
isVerifyEmailEnabled: false,
variables: [],
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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) => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Survey" ADD COLUMN "variables" JSONB NOT NULL DEFAULT '[]';

View File

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

View File

@@ -15,6 +15,7 @@ export {
ZSurveyWelcomeCard,
ZSurveyQuestions,
ZSurveyHiddenFields,
ZSurveyVariables,
ZSurveyClosedMessage,
ZSurveyProductOverwrites,
ZSurveyStyling,

View File

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

View File

@@ -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) => {

View File

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

View File

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

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

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