fix: merges the app and website js sdk into @formbricks/js (#3314)

Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
This commit is contained in:
Anshuman Pandey
2024-10-08 18:10:58 +05:30
committed by GitHub
parent 82a7b2276d
commit 2bfea919fe
161 changed files with 835 additions and 3446 deletions
@@ -0,0 +1,75 @@
/* eslint-disable no-console -- logging is allowed in migration scripts */
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
const TRANSACTION_TIMEOUT = 30 * 60 * 1000; // 30 minutes in milliseconds
async function runMigration(): Promise<void> {
const startTime = Date.now();
console.log("Starting data migration...");
await prisma.$transaction(
async (transactionPrisma) => {
const websiteSurveys = await transactionPrisma.survey.findMany({
where: { type: "website" },
});
const updationPromises = [];
for (const websiteSurvey of websiteSurveys) {
updationPromises.push(
transactionPrisma.survey.update({
where: { id: websiteSurvey.id },
data: {
type: "app",
segment: {
connectOrCreate: {
where: {
environmentId_title: {
environmentId: websiteSurvey.environmentId,
title: websiteSurvey.id,
},
},
create: {
title: websiteSurvey.id,
isPrivate: true,
environmentId: websiteSurvey.environmentId,
},
},
},
},
})
);
}
await Promise.all(updationPromises);
console.log(`Updated ${websiteSurveys.length.toString()} website surveys to app surveys`);
},
{
timeout: TRANSACTION_TIMEOUT,
}
);
const endTime = Date.now();
console.log(`Data migration completed. Total time: ${((endTime - startTime) / 1000).toFixed(2)}s`);
}
function handleError(error: unknown): void {
console.error("An error occurred during migration:", error);
process.exit(1);
}
function handleDisconnectError(): void {
console.error("Failed to disconnect Prisma client");
process.exit(1);
}
function main(): void {
runMigration()
.catch(handleError)
.finally(() => {
prisma.$disconnect().catch(handleDisconnectError);
});
}
main();
@@ -0,0 +1,8 @@
/*
Warnings:
- You are about to drop the column `websiteSetupCompleted` on the `Environment` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "Environment" DROP COLUMN "websiteSetupCompleted";
+2 -1
View File
@@ -51,7 +51,8 @@
"data-migration:add-display-id-to-response": "ts-node ./data-migrations/20240905120500_refactor_display_response_relationship/data-migration.ts",
"data-migration:address-question": "ts-node ./data-migrations/20240924123456_migrate_address_question/data-migration.ts",
"data-migration:advanced-logic": "ts-node ./data-migrations/20240828122408_advanced_logic_editor/data-migration.ts",
"data-migration:segments-actions-cleanup": "ts-node ./data-migrations/20240904091113_removed_actions_table/data-migration.ts"
"data-migration:segments-actions-cleanup": "ts-node ./data-migrations/20240904091113_removed_actions_table/data-migration.ts",
"data-migration:migrate-survey-types": "ts-node ./data-migrations/20241002123456_migrate_survey_types/data-migration.ts"
},
"dependencies": {
"@prisma/client": "^5.18.0",
+17 -18
View File
@@ -386,24 +386,23 @@ model Integration {
}
model Environment {
id String @id @default(cuid())
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @updatedAt @map(name: "updated_at")
type EnvironmentType
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
productId String
widgetSetupCompleted Boolean @default(false)
appSetupCompleted Boolean @default(false)
websiteSetupCompleted Boolean @default(false)
surveys Survey[]
people Person[]
actionClasses ActionClass[]
attributeClasses AttributeClass[]
apiKeys ApiKey[]
webhooks Webhook[]
tags Tag[]
segments Segment[]
integration Integration[]
id String @id @default(cuid())
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @updatedAt @map(name: "updated_at")
type EnvironmentType
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
productId String
widgetSetupCompleted Boolean @default(false)
appSetupCompleted Boolean @default(false)
surveys Survey[]
people Person[]
actionClasses ActionClass[]
attributeClasses AttributeClass[]
apiKeys ApiKey[]
webhooks Webhook[]
tags Tag[]
segments Segment[]
integration Integration[]
@@index([productId])
}
@@ -16,6 +16,7 @@ import type {
TSegmentUpdateInput,
} from "@formbricks/types/segment";
import type { TSurvey } from "@formbricks/types/surveys/types";
import { Alert, AlertDescription } from "@formbricks/ui/components/Alert";
import { AlertDialog } from "@formbricks/ui/components/AlertDialog";
import { Button } from "@formbricks/ui/components/Button";
import { LoadSegmentModal } from "@formbricks/ui/components/LoadSegmentModal";
@@ -161,7 +162,7 @@ export function AdvancedTargetingCard({
return (
<Collapsible.Root
className="w-full rounded-lg border border-slate-300 bg-white"
className="w-full overflow-hidden rounded-lg border border-slate-300 bg-white"
onOpenChange={setOpen}
open={open}>
<Collapsible.CollapsibleTrigger
@@ -415,6 +416,23 @@ export function AdvancedTargetingCard({
/>
</div>
</div>
<div>
<Alert className="flex items-center rounded-none bg-slate-50">
<AlertDescription className="ml-2">
<span className="mr-1 text-slate-600">
User targeting is currently only available when{" "}
<Link
href="https://formbricks.com//docs/app-surveys/user-identification"
target="blank"
className="underline">
identifying users
</Link>{" "}
with the Formbricks SDK.
</span>
</AlertDescription>
</Alert>
</div>
</Collapsible.CollapsibleContent>
</Collapsible.Root>
);
+6 -19
View File
@@ -20,33 +20,20 @@
"dist"
],
"exports": {
"./app": {
"import": "./dist/app.js",
"require": "./dist/app.umd.cjs",
"types": "./dist/app.d.ts"
},
"./website": {
"import": "./dist/website.js",
"require": "./dist/website.umd.cjs",
"types": "./dist/website.d.ts"
},
"./*": "./dist/*"
"import": "./dist/index.js",
"require": "./dist/index.umd.cjs",
"types": "./dist/index.d.ts"
},
"typesVersions": {
"*": {
"app": [
"./dist/app.d.ts"
],
"website": [
"./dist/website.d.ts"
"*": [
"./dist/index.d.ts"
]
}
},
"scripts": {
"dev": "vite build --watch --mode dev",
"build:app": "tsc && vite build --config app.vite.config.ts",
"build:website": "tsc && vite build --config website.vite.config.ts",
"build": "pnpm build:app && pnpm build:website",
"build": "tsc && vite build",
"build:dev": "tsc && vite build --mode dev",
"go": "vite build --watch --mode dev",
"lint": "eslint . --ext .ts,.js,.tsx,.jsx",
-43
View File
@@ -1,43 +0,0 @@
import { NetworkError, Result, err, okVoid } from "../../shared/errors";
import { Logger } from "../../shared/logger";
import { AppConfig } from "./config";
import { deinitalize, initialize } from "./initialize";
import { closeSurvey } from "./widget";
const appConfig = AppConfig.getInstance();
const logger = Logger.getInstance();
export const logoutPerson = async (): Promise<void> => {
deinitalize();
appConfig.resetConfig();
};
export const resetPerson = async (): Promise<Result<void, NetworkError>> => {
logger.debug("Resetting state & getting new state from backend");
closeSurvey();
const userId = appConfig.get().personState.data.userId;
if (!userId) {
return err({
code: "network_error",
status: 500,
message: "Missing userId",
url: `${appConfig.get().apiHost}/api/v1/client/${appConfig.get().environmentId}/people/${userId}/attributes`,
responseMessage: "Missing userId",
});
}
const syncParams = {
environmentId: appConfig.get().environmentId,
apiHost: appConfig.get().apiHost,
userId,
attributes: appConfig.get().personState.data.attributes,
};
await logoutPerson();
try {
await initialize(syncParams);
return okVoid();
} catch (e) {
return err(e as NetworkError);
}
};
@@ -1,11 +1,11 @@
import { TJsAppConfigInput, TJsTrackProperties } from "@formbricks/types/js";
import { CommandQueue } from "../shared/commandQueue";
import { ErrorHandler } from "../shared/errors";
import { Logger } from "../shared/logger";
import { TJsConfigInput, TJsTrackProperties } from "@formbricks/types/js";
import { trackCodeAction } from "./lib/actions";
import { getApi } from "./lib/api";
import { setAttributeInApp } from "./lib/attributes";
import { CommandQueue } from "./lib/commandQueue";
import { ErrorHandler } from "./lib/errors";
import { initialize } from "./lib/initialize";
import { Logger } from "./lib/logger";
import { checkPageUrl } from "./lib/noCodeActions";
import { logoutPerson, resetPerson } from "./lib/person";
@@ -14,9 +14,9 @@ const logger = Logger.getInstance();
logger.debug("Create command queue");
const queue = new CommandQueue();
const init = async (initConfig: TJsAppConfigInput) => {
const init = async (initConfig: TJsConfigInput) => {
ErrorHandler.init(initConfig.errorHandler);
queue.add(false, "app", initialize, initConfig);
queue.add(false, initialize, initConfig);
await queue.wait();
};
@@ -26,27 +26,27 @@ const setEmail = async (email: string): Promise<void> => {
};
const setAttribute = async (key: string, value: any): Promise<void> => {
queue.add(true, "app", setAttributeInApp, key, value);
queue.add(true, setAttributeInApp, key, value);
await queue.wait();
};
const logout = async (): Promise<void> => {
queue.add(true, "app", logoutPerson);
queue.add(true, logoutPerson);
await queue.wait();
};
const reset = async (): Promise<void> => {
queue.add(true, "app", resetPerson);
queue.add(true, resetPerson);
await queue.wait();
};
const track = async (name: string, properties?: TJsTrackProperties): Promise<void> => {
queue.add<any>(true, "app", trackCodeAction, name, properties);
queue.add<any>(true, trackCodeAction, name, properties);
await queue.wait();
};
const registerRouteChange = async (): Promise<void> => {
queue.add(true, "app", checkPageUrl);
queue.add(true, checkPageUrl);
await queue.wait();
};
@@ -1,11 +1,11 @@
import { TJsTrackProperties } from "@formbricks/types/js";
import { InvalidCodeError, NetworkError, Result, err, okVoid } from "../../shared/errors";
import { Logger } from "../../shared/logger";
import { AppConfig } from "./config";
import { Config } from "./config";
import { InvalidCodeError, NetworkError, Result, err, okVoid } from "./errors";
import { Logger } from "./logger";
import { triggerSurvey } from "./widget";
const logger = Logger.getInstance();
const appConfig = AppConfig.getInstance();
const config = Config.getInstance();
export const trackAction = async (
name: string,
@@ -17,7 +17,7 @@ export const trackAction = async (
logger.debug(`Formbricks: Action "${aliasName}" tracked`);
// get a list of surveys that are collecting insights
const activeSurveys = appConfig.get().filteredSurveys;
const activeSurveys = config.get().filteredSurveys;
if (!!activeSurveys && activeSurveys.length > 0) {
for (const survey of activeSurveys) {
@@ -38,7 +38,7 @@ export const trackCodeAction = (
code: string,
properties?: TJsTrackProperties
): Promise<Result<void, NetworkError>> | Result<void, InvalidCodeError> => {
const actionClasses = appConfig.get().environmentState.data.actionClasses;
const actionClasses = config.get().environmentState.data.actionClasses;
const codeActionClasses = actionClasses.filter((action) => action.type === "code");
const action = codeActionClasses.find((action) => action.key === code);
@@ -1,9 +1,9 @@
import { FormbricksAPI } from "@formbricks/api";
import { AppConfig } from "./config";
import { Config } from "./config";
export const getApi = (): FormbricksAPI => {
const inAppConfig = AppConfig.getInstance();
const { environmentId, apiHost } = inAppConfig.get();
const config = Config.getInstance();
const { environmentId, apiHost } = config.get();
if (!environmentId || !apiHost) {
throw new Error("formbricks.init() must be called before getApi()");
@@ -1,12 +1,12 @@
import { FormbricksAPI } from "@formbricks/api";
import { TAttributes } from "@formbricks/types/attributes";
import { MissingPersonError, NetworkError, Result, err, ok, okVoid } from "../../shared/errors";
import { Logger } from "../../shared/logger";
import { fetchPersonState } from "../../shared/personState";
import { filterSurveys } from "../../shared/utils";
import { AppConfig } from "./config";
import { Config } from "./config";
import { MissingPersonError, NetworkError, Result, err, ok, okVoid } from "./errors";
import { Logger } from "./logger";
import { fetchPersonState } from "./personState";
import { filterSurveys } from "./utils";
const appConfig = AppConfig.getInstance();
const config = Config.getInstance();
const logger = Logger.getInstance();
export const updateAttribute = async (
@@ -21,8 +21,8 @@ export const updateAttribute = async (
Error | NetworkError
>
> => {
const { apiHost, environmentId } = appConfig.get();
const userId = appConfig.get().personState.data.userId;
const { apiHost, environmentId } = config.get();
const userId = config.get().personState.data.userId;
if (!userId) {
return err({
@@ -58,7 +58,7 @@ export const updateAttribute = async (
// @ts-expect-error
status: res.error.status ?? 500,
message: res.error.message ?? `Error updating person with userId ${userId}`,
url: `${appConfig.get().apiHost}/api/v1/client/${environmentId}/people/${userId}/attributes`,
url: `${config.get().apiHost}/api/v1/client/${environmentId}/people/${userId}/attributes`,
responseMessage: res.error.message,
});
}
@@ -93,7 +93,7 @@ export const updateAttributes = async (
const updatedAttributes = { ...attributes };
try {
const existingAttributes = appConfig.get().personState.data.attributes;
const existingAttributes = config.get().personState.data.attributes;
if (existingAttributes) {
for (const [key, value] of Object.entries(existingAttributes)) {
if (updatedAttributes[key] === value) {
@@ -140,7 +140,7 @@ export const updateAttributes = async (
};
export const isExistingAttribute = (key: string, value: string): boolean => {
if (appConfig.get().personState.data.attributes[key] === value) {
if (config.get().personState.data.attributes[key] === value) {
return true;
}
@@ -156,14 +156,7 @@ export const setAttributeInApp = async (
return okVoid();
}
const userId = appConfig.get().personState.data.userId;
if (!userId) {
return err({
code: "missing_person",
message: "Missing userId",
});
}
const userId = config.get().personState.data.userId;
logger.debug("Setting attribute: " + key + " to value: " + value);
// check if attribute already exists with this value
@@ -172,23 +165,30 @@ export const setAttributeInApp = async (
return okVoid();
}
if (!userId) {
logger.error(
"UserId not provided, please provide a userId in the init method before setting attributes."
);
return okVoid();
}
const result = await updateAttribute(key, value.toString());
if (result.ok) {
if (result.value.changed) {
const personState = await fetchPersonState(
{
apiHost: appConfig.get().apiHost,
environmentId: appConfig.get().environmentId,
apiHost: config.get().apiHost,
environmentId: config.get().environmentId,
userId,
},
true
);
const filteredSurveys = filterSurveys(appConfig.get().environmentState, personState);
const filteredSurveys = filterSurveys(config.get().environmentState, personState);
appConfig.update({
...appConfig.get(),
config.update({
...config.get(),
personState,
filteredSurveys,
});
@@ -1,13 +1,10 @@
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";
import { checkInitialized } from "./initialize";
export class CommandQueue {
private queue: {
command: (args: any) => Promise<Result<void, any>> | Result<void, any> | Promise<void>;
packageType: TJsPackageType;
checkInitialized: boolean;
commandArgs: any[any];
}[] = [];
@@ -17,11 +14,10 @@ export class CommandQueue {
public add<A>(
checkInitialized: boolean = true,
packageType: TJsPackageType,
command: (...args: A[]) => Promise<Result<void, any>> | Result<void, any> | Promise<void>,
...args: A[]
) {
this.queue.push({ command, checkInitialized, commandArgs: args, packageType });
this.queue.push({ command, checkInitialized, commandArgs: args });
if (!this.running) {
this.commandPromise = new Promise((resolve) => {
@@ -48,8 +44,7 @@ export class CommandQueue {
// make sure formbricks is initialized
if (currentItem.checkInitialized) {
// call different function based on package type
const initResult =
currentItem.packageType === "website" ? checkInitializedWebsite() : checkInitializedInApp();
const initResult = checkInitialized();
if (initResult && initResult.ok !== true) {
errorHandler.handle(initResult.error);
@@ -1,9 +1,9 @@
import { TJsConfig, TJsConfigUpdateInput } from "@formbricks/types/js";
import { APP_SURVEYS_LOCAL_STORAGE_KEY } from "../../shared/constants";
import { Result, err, ok, wrapThrows } from "../../shared/errors";
import { JS_LOCAL_STORAGE_KEY } from "./constants";
import { Result, err, ok, wrapThrows } from "./errors";
export class AppConfig {
private static instance: AppConfig | undefined;
export class Config {
private static instance: Config | undefined;
private config: TJsConfig | null = null;
private constructor() {
@@ -14,11 +14,11 @@ export class AppConfig {
}
}
static getInstance(): AppConfig {
if (!AppConfig.instance) {
AppConfig.instance = new AppConfig();
static getInstance(): Config {
if (!Config.instance) {
Config.instance = new Config();
}
return AppConfig.instance;
return Config.instance;
}
public update(newConfig: TJsConfigUpdateInput): void {
@@ -45,7 +45,7 @@ export class AppConfig {
public loadFromLocalStorage(): Result<TJsConfig, Error> {
if (typeof window !== "undefined") {
const savedConfig = localStorage.getItem(APP_SURVEYS_LOCAL_STORAGE_KEY);
const savedConfig = localStorage.getItem(JS_LOCAL_STORAGE_KEY);
if (savedConfig) {
// TODO: validate config
// This is a hack to get around the fact that we don't have a proper
@@ -69,7 +69,7 @@ export class AppConfig {
private async saveToStorage(): Promise<Result<Promise<void>, Error>> {
return wrapThrows(async () => {
await localStorage.setItem(APP_SURVEYS_LOCAL_STORAGE_KEY, JSON.stringify(this.config));
await localStorage.setItem(JS_LOCAL_STORAGE_KEY, JSON.stringify(this.config));
})();
}
@@ -79,7 +79,7 @@ export class AppConfig {
this.config = null;
return wrapThrows(async () => {
localStorage.removeItem(APP_SURVEYS_LOCAL_STORAGE_KEY);
localStorage.removeItem(JS_LOCAL_STORAGE_KEY);
})();
}
}
+5
View File
@@ -0,0 +1,5 @@
export const RN_ASYNC_STORAGE_KEY = "formbricks-react-native";
export const JS_LOCAL_STORAGE_KEY = "formbricks-js";
export const LEGACY_JS_WEBSITE_LOCAL_STORAGE_KEY = "formbricks-js-website";
export const LEGACY_JS_APP_LOCAL_STORAGE_KEY = "formbricks-js-app";
export const CONTAINER_ID = "formbricks-app-container";
@@ -1,11 +1,11 @@
// shared functions for environment and person state(s)
import { TJsEnvironmentState, TJsEnvironmentSyncParams } from "@formbricks/types/js";
import { AppConfig } from "../app/lib/config";
import { WebsiteConfig } from "../website/lib/config";
import { Config } from "./config";
import { err } from "./errors";
import { Logger } from "./logger";
import { filterSurveys, getIsDebug } from "./utils";
const config = Config.getInstance();
const logger = Logger.getInstance();
let environmentStateSyncIntervalId: number | null = null;
@@ -19,7 +19,6 @@ let environmentStateSyncIntervalId: number | null = null;
*/
export const fetchEnvironmentState = async (
{ apiHost, environmentId }: TJsEnvironmentSyncParams,
sdkType: "app" | "website",
noCache: boolean = false
): Promise<TJsEnvironmentState> => {
let fetchOptions: RequestInit = {};
@@ -29,7 +28,7 @@ export const fetchEnvironmentState = async (
logger.debug("No cache option set for sync");
}
const url = `${apiHost}/api/v1/client/${environmentId}/${sdkType}/environment`;
const url = `${apiHost}/api/v1/client/${environmentId}/environment`;
const response = await fetch(url, fetchOptions);
@@ -56,10 +55,7 @@ export const fetchEnvironmentState = async (
};
};
export const addEnvironmentStateExpiryCheckListener = (
sdkType: "app" | "website",
config: AppConfig | WebsiteConfig
): void => {
export const addEnvironmentStateExpiryCheckListener = (): void => {
let updateInterval = 1000 * 60; // every minute
if (typeof window !== "undefined" && environmentStateSyncIntervalId === null) {
environmentStateSyncIntervalId = window.setInterval(async () => {
@@ -79,7 +75,6 @@ export const addEnvironmentStateExpiryCheckListener = (
apiHost: config.get().apiHost,
environmentId: config.get().environmentId,
},
sdkType,
true
);
@@ -1,11 +1,7 @@
import {
addEnvironmentStateExpiryCheckListener,
clearEnvironmentStateExpiryCheckListener,
} from "../../shared/environmentState";
import {
addPersonStateExpiryCheckListener,
clearPersonStateExpiryCheckListener,
} from "../../shared/personState";
} from "./environmentState";
import {
addClickEventListener,
addExitIntentListener,
@@ -15,14 +11,14 @@ import {
removeExitIntentListener,
removePageUrlEventListeners,
removeScrollDepthListener,
} from "../lib/noCodeActions";
import { AppConfig } from "./config";
} from "./noCodeActions";
import { addPersonStateExpiryCheckListener, clearPersonStateExpiryCheckListener } from "./personState";
let areRemoveEventListenersAdded = false;
export const addEventListeners = (config: AppConfig): void => {
addEnvironmentStateExpiryCheckListener("app", config);
addPersonStateExpiryCheckListener(config);
export const addEventListeners = (): void => {
addEnvironmentStateExpiryCheckListener();
addPersonStateExpiryCheckListener();
addPageUrlEventListeners();
addClickEventListener();
addExitIntentListener();
@@ -1,7 +1,14 @@
import { TAttributes } from "@formbricks/types/attributes";
import type { TJsAppConfigInput, TJsConfig } from "@formbricks/types/js";
import { APP_SURVEYS_LOCAL_STORAGE_KEY } from "../../shared/constants";
import { fetchEnvironmentState } from "../../shared/environmentState";
import { type TJsConfig, type TJsConfigInput } from "@formbricks/types/js";
import { trackNoCodeAction } from "./actions";
import { updateAttributes } from "./attributes";
import { Config } from "./config";
import {
JS_LOCAL_STORAGE_KEY,
LEGACY_JS_APP_LOCAL_STORAGE_KEY,
LEGACY_JS_WEBSITE_LOCAL_STORAGE_KEY,
} from "./constants";
import { fetchEnvironmentState } from "./environmentState";
import {
ErrorHandler,
MissingFieldError,
@@ -12,18 +19,14 @@ import {
err,
okVoid,
wrapThrows,
} from "../../shared/errors";
import { Logger } from "../../shared/logger";
import { fetchPersonState } from "../../shared/personState";
import { filterSurveys, getIsDebug } from "../../shared/utils";
import { trackNoCodeAction } from "./actions";
import { updateAttributes } from "./attributes";
import { AppConfig } from "./config";
} from "./errors";
import { addCleanupEventListeners, addEventListeners, removeAllEventListeners } from "./eventListeners";
import { Logger } from "./logger";
import { checkPageUrl } from "./noCodeActions";
import { DEFAULT_PERSON_STATE_NO_USER_ID, fetchPersonState } from "./personState";
import { filterSurveys, getIsDebug } from "./utils";
import { addWidgetContainer, removeWidgetContainer, setIsSurveyRunning } from "./widget";
const appConfigGlobal = AppConfig.getInstance();
const logger = Logger.getInstance();
let isInitialized = false;
@@ -32,38 +35,72 @@ export const setIsInitialized = (value: boolean) => {
isInitialized = value;
};
const checkForOlderLocalConfig = (): boolean => {
const oldConfig = localStorage.getItem(APP_SURVEYS_LOCAL_STORAGE_KEY);
const migrateLocalStorage = (): { changed: boolean; newState?: TJsConfig } => {
const oldWebsiteConfig = localStorage.getItem(LEGACY_JS_WEBSITE_LOCAL_STORAGE_KEY);
const oldAppConfig = localStorage.getItem(LEGACY_JS_APP_LOCAL_STORAGE_KEY);
if (oldConfig) {
const parsedOldConfig = JSON.parse(oldConfig);
if (parsedOldConfig.state || parsedOldConfig.expiresAt) {
// local config follows old structure
return true;
if (oldWebsiteConfig) {
localStorage.removeItem(LEGACY_JS_WEBSITE_LOCAL_STORAGE_KEY);
const parsedOldConfig = JSON.parse(oldWebsiteConfig) as TJsConfig;
if (
parsedOldConfig.environmentId &&
parsedOldConfig.apiHost &&
parsedOldConfig.environmentState &&
parsedOldConfig.personState &&
parsedOldConfig.filteredSurveys
) {
const newLocalStorageConfig = { ...parsedOldConfig };
return {
changed: true,
newState: newLocalStorageConfig,
};
}
}
return false;
if (oldAppConfig) {
localStorage.removeItem(LEGACY_JS_APP_LOCAL_STORAGE_KEY);
const parsedOldConfig = JSON.parse(oldAppConfig) as TJsConfig;
if (
parsedOldConfig.environmentId &&
parsedOldConfig.apiHost &&
parsedOldConfig.environmentState &&
parsedOldConfig.personState &&
parsedOldConfig.filteredSurveys
) {
return {
changed: true,
};
}
}
return {
changed: false,
};
};
export const initialize = async (
configInput: TJsAppConfigInput
configInput: TJsConfigInput
): Promise<Result<void, MissingFieldError | NetworkError | MissingPersonError>> => {
const isDebug = getIsDebug();
if (isDebug) {
logger.configure({ logLevel: "debug" });
}
let config = Config.getInstance();
const isLocalStorageOld = checkForOlderLocalConfig();
const { changed, newState } = migrateLocalStorage();
let appConfig = appConfigGlobal;
if (changed) {
config.resetConfig();
config = Config.getInstance();
if (isLocalStorageOld) {
logger.debug("Local config is of an older version");
logger.debug("Resetting config");
appConfig.resetConfig();
appConfig = AppConfig.getInstance();
// If the js sdk is being used for non identified users, and we have a new state to update to after migrating, we update the state
// otherwise, we just sync again!
if (!configInput.userId && newState) {
config.update(newState);
}
}
if (isInitialized) {
@@ -73,7 +110,7 @@ export const initialize = async (
let existingConfig: TJsConfig | undefined;
try {
existingConfig = appConfigGlobal.get();
existingConfig = config.get();
logger.debug("Found existing configuration.");
} catch (e) {
logger.debug("No existing configuration found.");
@@ -85,7 +122,7 @@ export const initialize = async (
logger.debug(
"Formbricks is in error state, but debug mode is active. Resetting config and continuing."
);
appConfigGlobal.resetConfig();
config.resetConfig();
return okVoid();
}
@@ -122,38 +159,32 @@ export const initialize = async (
});
}
if (!configInput.userId) {
logger.debug("No userId provided");
return err({
code: "missing_field",
field: "userId",
});
}
logger.debug("Adding widget container to DOM");
addWidgetContainer();
let updatedAttributes: TAttributes | null = null;
if (configInput.attributes) {
const res = await updateAttributes(
configInput.apiHost,
configInput.environmentId,
configInput.userId,
configInput.attributes
);
if (res.ok !== true) {
return err(res.error);
if (configInput.userId) {
const res = await updateAttributes(
configInput.apiHost,
configInput.environmentId,
configInput.userId,
configInput.attributes
);
if (res.ok !== true) {
return err(res.error);
}
updatedAttributes = res.value;
} else {
updatedAttributes = { ...configInput.attributes };
}
updatedAttributes = res.value;
}
if (
existingConfig &&
existingConfig.environmentState &&
existingConfig.environmentId === configInput.environmentId &&
existingConfig.apiHost === configInput.apiHost &&
existingConfig.personState?.data?.userId === configInput.userId
existingConfig.apiHost === configInput.apiHost
) {
logger.debug("Configuration fits init parameters.");
let isEnvironmentStateExpired = false;
@@ -164,7 +195,12 @@ export const initialize = async (
isEnvironmentStateExpired = true;
}
if (existingConfig.personState.expiresAt && new Date(existingConfig.personState.expiresAt) < new Date()) {
// if the config has a userId and the person state has expired, we need to sync the person state
if (
configInput.userId &&
existingConfig.personState.expiresAt &&
new Date(existingConfig.personState.expiresAt) < new Date()
) {
logger.debug("Person state expired. Syncing.");
isPersonStateExpired = true;
}
@@ -172,29 +208,33 @@ export const initialize = async (
try {
// fetch the environment state (if expired)
const environmentState = isEnvironmentStateExpired
? await fetchEnvironmentState(
{
apiHost: configInput.apiHost,
environmentId: configInput.environmentId,
},
"app"
)
? await fetchEnvironmentState({
apiHost: configInput.apiHost,
environmentId: configInput.environmentId,
})
: existingConfig.environmentState;
// fetch the person state (if expired)
const personState = isPersonStateExpired
? await fetchPersonState({
let { personState } = existingConfig;
if (isPersonStateExpired) {
if (configInput.userId) {
personState = await fetchPersonState({
apiHost: configInput.apiHost,
environmentId: configInput.environmentId,
userId: configInput.userId,
})
: existingConfig.personState;
});
} else {
personState = DEFAULT_PERSON_STATE_NO_USER_ID;
}
}
// filter the environment state wrt the person state
const filteredSurveys = filterSurveys(environmentState, personState);
// update the appConfig with the new filtered surveys
appConfigGlobal.update({
config.update({
...existingConfig,
environmentState,
personState,
@@ -204,13 +244,13 @@ export const initialize = async (
const surveyNames = filteredSurveys.map((s) => s.name);
logger.debug("Fetched " + surveyNames.length + " surveys during sync: " + surveyNames.join(", "));
} catch (e) {
putFormbricksInErrorState(appConfig);
putFormbricksInErrorState(config);
}
} else {
logger.debug(
"No valid configuration found or it has been expired. Resetting config and creating new one."
);
appConfigGlobal.resetConfig();
config.resetConfig();
logger.debug("Syncing.");
try {
@@ -219,21 +259,22 @@ export const initialize = async (
apiHost: configInput.apiHost,
environmentId: configInput.environmentId,
},
"app",
false
);
const personState = await fetchPersonState(
{
apiHost: configInput.apiHost,
environmentId: configInput.environmentId,
userId: configInput.userId,
},
false
);
const personState = configInput.userId
? await fetchPersonState(
{
apiHost: configInput.apiHost,
environmentId: configInput.environmentId,
userId: configInput.userId,
},
false
)
: DEFAULT_PERSON_STATE_NO_USER_ID;
const filteredSurveys = filterSurveys(environmentState, personState);
appConfigGlobal.update({
config.update({
apiHost: configInput.apiHost,
environmentId: configInput.environmentId,
personState,
@@ -250,14 +291,14 @@ export const initialize = async (
// update attributes in config
if (updatedAttributes && Object.keys(updatedAttributes).length > 0) {
appConfigGlobal.update({
...appConfigGlobal.get(),
config.update({
...config.get(),
personState: {
...appConfigGlobal.get().personState,
...config.get().personState,
data: {
...appConfigGlobal.get().personState.data,
...config.get().personState.data,
attributes: {
...appConfigGlobal.get().personState.data.attributes,
...config.get().personState.data.attributes,
...updatedAttributes,
},
},
@@ -266,7 +307,7 @@ export const initialize = async (
}
logger.debug("Adding event listeners");
addEventListeners(appConfigGlobal);
addEventListeners();
addCleanupEventListeners();
setIsInitialized(true);
@@ -293,7 +334,7 @@ export const handleErrorOnFirstInit = () => {
};
// can't use config.update here because the config is not yet initialized
wrapThrows(() => localStorage.setItem(APP_SURVEYS_LOCAL_STORAGE_KEY, JSON.stringify(initialErrorConfig)))();
wrapThrows(() => localStorage.setItem(JS_LOCAL_STORAGE_KEY, JSON.stringify(initialErrorConfig)))();
throw new Error("Could not initialize formbricks");
};
@@ -317,7 +358,7 @@ export const deinitalize = (): void => {
setIsInitialized(false);
};
export const putFormbricksInErrorState = (appConfig: AppConfig): void => {
export const putFormbricksInErrorState = (config: Config): void => {
if (getIsDebug()) {
logger.debug("Not putting formbricks in error state because debug mode is active (no error state)");
return;
@@ -325,8 +366,8 @@ export const putFormbricksInErrorState = (appConfig: AppConfig): void => {
logger.debug("Putting formbricks in error state");
// change formbricks status to error
appConfig.update({
...appConfigGlobal.get(),
config.update({
...config.get(),
status: {
value: "error",
expiresAt: new Date(new Date().getTime() + 10 * 60000), // 10 minutes in the future
@@ -1,11 +1,11 @@
import type { TActionClass } from "@formbricks/types/action-classes";
import { ErrorHandler, NetworkError, Result, err, match, okVoid } from "../../shared/errors";
import { Logger } from "../../shared/logger";
import { evaluateNoCodeConfigClick, handleUrlFilters } from "../../shared/utils";
import { trackNoCodeAction } from "./actions";
import { AppConfig } from "./config";
import { Config } from "./config";
import { ErrorHandler, NetworkError, Result, err, match, okVoid } from "./errors";
import { Logger } from "./logger";
import { evaluateNoCodeConfigClick, handleUrlFilters } from "./utils";
const appConfig = AppConfig.getInstance();
const appConfig = Config.getInstance();
const logger = Logger.getInstance();
const errorHandler = ErrorHandler.getInstance();
+34
View File
@@ -0,0 +1,34 @@
import { Config } from "./config";
import { NetworkError, Result, err, okVoid } from "./errors";
import { deinitalize, initialize } from "./initialize";
import { Logger } from "./logger";
import { closeSurvey } from "./widget";
const config = Config.getInstance();
const logger = Logger.getInstance();
export const logoutPerson = async (): Promise<void> => {
deinitalize();
config.resetConfig();
};
export const resetPerson = async (): Promise<Result<void, NetworkError>> => {
logger.debug("Resetting state & getting new state from backend");
closeSurvey();
const userId = config.get().personState.data.userId;
const syncParams = {
environmentId: config.get().environmentId,
apiHost: config.get().apiHost,
...(userId && { userId }),
attributes: config.get().personState.data.attributes,
};
await logoutPerson();
try {
await initialize(syncParams);
return okVoid();
} catch (e) {
return err(e as NetworkError);
}
};
@@ -1,13 +1,14 @@
import { TJsPersonState, TJsPersonSyncParams } from "@formbricks/types/js";
import { AppConfig } from "../app/lib/config";
import { Config } from "./config";
import { err } from "./errors";
import { Logger } from "./logger";
import { getIsDebug } from "./utils";
const config = Config.getInstance();
const logger = Logger.getInstance();
let personStateSyncIntervalId: number | null = null;
export const DEFAULT_PERSON_STATE_WEBSITE: TJsPersonState = {
export const DEFAULT_PERSON_STATE_NO_USER_ID: TJsPersonState = {
expiresAt: null,
data: {
userId: null,
@@ -39,7 +40,7 @@ export const fetchPersonState = async (
logger.debug("No cache option set for sync");
}
const url = `${apiHost}/api/v1/client/${environmentId}/app/people/${userId}`;
const url = `${apiHost}/api/v1/client/${environmentId}/identify/people/${userId}`;
const response = await fetch(url, fetchOptions);
@@ -60,6 +61,8 @@ export const fetchPersonState = async (
const data = await response.json();
const { data: state } = data;
console.log("Person state fetched", state);
const defaultPersonState: TJsPersonState = {
expiresAt: new Date(new Date().getTime() + 1000 * 60 * 30), // 30 minutes
data: {
@@ -84,25 +87,24 @@ export const fetchPersonState = async (
/**
* Add a listener to check if the person state has expired with a certain interval
* @param appConfig - The app config
* @param config - The configuration for the SDK
*/
export const addPersonStateExpiryCheckListener = (appConfig: AppConfig): void => {
export const addPersonStateExpiryCheckListener = (): void => {
const updateInterval = 1000 * 60; // every 60 seconds
if (typeof window !== "undefined" && personStateSyncIntervalId === null) {
personStateSyncIntervalId = window.setInterval(async () => {
const userId = appConfig.get().personState.data.userId;
const userId = config.get().personState.data.userId;
if (!userId) {
return;
}
// extend the personState validity by 30 minutes:
appConfig.update({
...appConfig.get(),
config.update({
...config.get(),
personState: {
...appConfig.get().personState,
...config.get().personState,
expiresAt: new Date(new Date().getTime() + 1000 * 60 * 30), // 30 minutes
},
});
@@ -158,11 +158,10 @@ export const getIsDebug = () => window.location.search.includes("formbricksDebug
// takes the environment and person state and returns the filtered surveys
export const filterSurveys = (
environmentState: TJsEnvironmentState,
personState: TJsPersonState,
sdkType: "app" | "website" = "app"
personState: TJsPersonState
): TSurvey[] => {
const { product, surveys } = environmentState.data;
const { displays, responses, lastDisplayAt, segments } = personState.data;
const { displays, responses, lastDisplayAt, segments, userId } = personState.data;
if (!displays) {
return [];
@@ -220,7 +219,7 @@ export const filterSurveys = (
}
});
if (sdkType === "website") {
if (!userId) {
return filteredSurveys;
}
@@ -6,19 +6,18 @@ import { TJsFileUploadParams, TJsPersonState, TJsTrackProperties } from "@formbr
import { TResponseHiddenFieldValue, TResponseUpdate } from "@formbricks/types/responses";
import { TUploadFileConfig } from "@formbricks/types/storage";
import { TSurvey } from "@formbricks/types/surveys/types";
import { Logger } from "../../shared/logger";
import { Config } from "./config";
import { CONTAINER_ID } from "./constants";
import { Logger } from "./logger";
import {
filterSurveys,
getDefaultLanguageCode,
getLanguageCode,
handleHiddenFields,
shouldDisplayBasedOnPercentage,
} from "../../shared/utils";
import { AppConfig } from "./config";
} from "./utils";
const containerId = "formbricks-app-container";
const appConfig = AppConfig.getInstance();
const config = Config.getInstance();
const logger = Logger.getInstance();
let isSurveyRunning = false;
let setIsError = (_: boolean) => {};
@@ -65,8 +64,8 @@ const renderWidget = async (
logger.debug(`Delaying survey "${survey.name}" by ${survey.delay} seconds.`);
}
const { product } = appConfig.get().environmentState.data ?? {};
const { attributes } = appConfig.get().personState.data ?? {};
const { product } = config.get().environmentState.data ?? {};
const { attributes } = config.get().personState.data ?? {};
const isMultiLanguageSurvey = survey.languages.length > 1;
let languageCode = "default";
@@ -82,12 +81,12 @@ const renderWidget = async (
languageCode = displayLanguage;
}
const surveyState = new SurveyState(survey.id, null, null, appConfig.get().personState.data.userId);
const surveyState = new SurveyState(survey.id, null, null, config.get().personState.data.userId);
const responseQueue = new ResponseQueue(
{
apiHost: appConfig.get().apiHost,
environmentId: appConfig.get().environmentId,
apiHost: config.get().apiHost,
environmentId: config.get().environmentId,
retryAttempts: 2,
onResponseSendingFailed: () => {
setIsError(true);
@@ -121,21 +120,16 @@ const renderWidget = async (
setIsResponseSendingFinished = f;
},
onDisplay: async () => {
const { userId } = appConfig.get().personState.data;
if (!userId) {
logger.debug("User ID not found. Skipping.");
return;
}
const { userId } = config.get().personState.data;
const api = new FormbricksAPI({
apiHost: appConfig.get().apiHost,
environmentId: appConfig.get().environmentId,
apiHost: config.get().apiHost,
environmentId: config.get().environmentId,
});
const res = await api.client.display.create({
surveyId: survey.id,
userId,
...(userId && { userId }),
});
if (!res.ok) {
@@ -147,10 +141,10 @@ const renderWidget = async (
surveyState.updateDisplayId(id);
responseQueue.updateSurveyState(surveyState);
const existingDisplays = appConfig.get().personState.data.displays;
const existingDisplays = config.get().personState.data.displays;
const newDisplay = { surveyId: survey.id, createdAt: new Date() };
const displays = existingDisplays ? [...existingDisplays, newDisplay] : [newDisplay];
const previousConfig = appConfig.get();
const previousConfig = config.get();
const updatedPersonState: TJsPersonState = {
...previousConfig.personState,
@@ -163,23 +157,21 @@ const renderWidget = async (
const filteredSurveys = filterSurveys(previousConfig.environmentState, updatedPersonState);
appConfig.update({
config.update({
...previousConfig,
environmentState: previousConfig.environmentState,
personState: updatedPersonState,
filteredSurveys,
});
},
onResponse: (responseUpdate: TResponseUpdate) => {
const { userId } = appConfig.get().personState.data;
if (!userId) {
logger.debug("User ID not found. Skipping.");
return;
}
const { userId } = config.get().personState.data;
const isNewResponse = surveyState.responseId === null;
surveyState.updateUserId(userId);
if (userId) {
surveyState.updateUserId(userId);
}
responseQueue.updateSurveyState(surveyState);
responseQueue.add({
@@ -198,20 +190,20 @@ const renderWidget = async (
});
if (isNewResponse) {
const responses = appConfig.get().personState.data.responses;
const responses = config.get().personState.data.responses;
const newPersonState: TJsPersonState = {
...appConfig.get().personState,
...config.get().personState,
data: {
...appConfig.get().personState.data,
...config.get().personState.data,
responses: [...responses, surveyState.surveyId],
},
};
const filteredSurveys = filterSurveys(appConfig.get().environmentState, newPersonState);
const filteredSurveys = filterSurveys(config.get().environmentState, newPersonState);
appConfig.update({
...appConfig.get(),
environmentState: appConfig.get().environmentState,
config.update({
...config.get(),
environmentState: config.get().environmentState,
personState: newPersonState,
filteredSurveys,
});
@@ -220,8 +212,8 @@ const renderWidget = async (
onClose: closeSurvey,
onFileUpload: async (file: TJsFileUploadParams["file"], params: TUploadFileConfig) => {
const api = new FormbricksAPI({
apiHost: appConfig.get().apiHost,
environmentId: appConfig.get().environmentId,
apiHost: config.get().apiHost,
environmentId: config.get().environmentId,
});
return await api.client.storage.uploadFile(
@@ -247,11 +239,11 @@ export const closeSurvey = async (): Promise<void> => {
removeWidgetContainer();
addWidgetContainer();
const { environmentState, personState } = appConfig.get();
const { environmentState, personState } = config.get();
const filteredSurveys = filterSurveys(environmentState, personState);
appConfig.update({
...appConfig.get(),
config.update({
...config.get(),
environmentState,
personState,
filteredSurveys,
@@ -262,12 +254,12 @@ export const closeSurvey = async (): Promise<void> => {
export const addWidgetContainer = (): void => {
const containerElement = document.createElement("div");
containerElement.id = containerId;
containerElement.id = CONTAINER_ID;
document.body.appendChild(containerElement);
};
export const removeWidgetContainer = (): void => {
document.getElementById(containerId)?.remove();
document.getElementById(CONTAINER_ID)?.remove();
};
const loadFormbricksSurveysExternally = (): Promise<typeof window.formbricksSurveys> => {
@@ -276,7 +268,7 @@ const loadFormbricksSurveysExternally = (): Promise<typeof window.formbricksSurv
resolve(window.formbricksSurveys);
} else {
const script = document.createElement("script");
script.src = `${appConfig.get().apiHost}/api/packages/surveys`;
script.src = `${config.get().apiHost}/api/packages/surveys`;
script.async = true;
script.onload = () => resolve(window.formbricksSurveys);
script.onerror = (error) => {
-3
View File
@@ -1,3 +0,0 @@
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";
-46
View File
@@ -1,46 +0,0 @@
import { TJsTrackProperties, TJsWebsiteConfigInput } from "@formbricks/types/js";
// Shared imports
import { CommandQueue } from "../shared/commandQueue";
import { ErrorHandler } from "../shared/errors";
import { Logger } from "../shared/logger";
// Website package specific imports
import { trackCodeAction } from "./lib/actions";
import { resetConfig } from "./lib/common";
import { initialize } from "./lib/initialize";
import { checkPageUrl } from "./lib/noCodeActions";
const logger = Logger.getInstance();
logger.debug("Create command queue");
const queue = new CommandQueue();
const init = async (initConfig: TJsWebsiteConfigInput) => {
ErrorHandler.init(initConfig.errorHandler);
queue.add(false, "website", initialize, initConfig);
await queue.wait();
};
const reset = async (): Promise<void> => {
queue.add(true, "website", resetConfig);
await queue.wait();
};
const track = async (name: string, properties?: TJsTrackProperties): Promise<void> => {
queue.add<any>(true, "website", trackCodeAction, name, properties);
await queue.wait();
};
const registerRouteChange = async (): Promise<void> => {
queue.add(true, "website", checkPageUrl);
await queue.wait();
};
const formbricks = {
init,
track,
reset,
registerRouteChange,
};
export type TFormbricksWebsite = typeof formbricks;
export default formbricks as TFormbricksWebsite;
@@ -1,57 +0,0 @@
import { TJsTrackProperties } from "@formbricks/types/js";
import { InvalidCodeError, NetworkError, Result, err, okVoid } from "../../shared/errors";
import { Logger } from "../../shared/logger";
import { WebsiteConfig } from "./config";
import { triggerSurvey } from "./widget";
const logger = Logger.getInstance();
const websiteConfig = WebsiteConfig.getInstance();
export const trackAction = async (
name: string,
alias?: string,
properties?: TJsTrackProperties
): Promise<Result<void, NetworkError>> => {
const aliasName = alias || name;
logger.debug(`Formbricks: Action "${aliasName}" tracked`);
// get a list of surveys that are collecting insights
const activeSurveys = websiteConfig.get().filteredSurveys;
if (!!activeSurveys && activeSurveys.length > 0) {
for (const survey of activeSurveys) {
for (const trigger of survey.triggers) {
if (trigger.actionClass.name === name) {
await triggerSurvey(survey, name, properties);
}
}
}
} else {
logger.debug("No active surveys to display");
}
return okVoid();
};
export const trackCodeAction = (
code: string,
properties?: TJsTrackProperties
): Promise<Result<void, NetworkError>> | Result<void, InvalidCodeError> => {
const actionClasses = websiteConfig.get().environmentState.data.actionClasses;
const codeActionClasses = actionClasses.filter((action) => action.type === "code");
const action = codeActionClasses.find((action) => action.key === code);
if (!action) {
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(action.name, code, properties);
};
export const trackNoCodeAction = (name: string): Promise<Result<void, NetworkError>> => {
return trackAction(name);
};
@@ -1,31 +0,0 @@
import { NetworkError, Result, err, okVoid } from "../../shared/errors";
import { Logger } from "../../shared/logger";
import { WebsiteConfig } from "./config";
import { deinitalize, initialize } from "./initialize";
import { closeSurvey } from "./widget";
const websiteConfig = WebsiteConfig.getInstance();
const logger = Logger.getInstance();
export const resetWebsiteConfig = async (): Promise<void> => {
deinitalize();
websiteConfig.resetConfig();
};
export const resetConfig = async (): Promise<Result<void, NetworkError>> => {
logger.debug("Resetting state & getting new state from backend");
closeSurvey();
const syncParams = {
environmentId: websiteConfig.get().environmentId,
apiHost: websiteConfig.get().apiHost,
};
await resetWebsiteConfig();
try {
await initialize(syncParams);
return okVoid();
} catch (e) {
return err(e as NetworkError);
}
};
@@ -1,86 +0,0 @@
import { TJsConfig, TJsConfigUpdateInput } from "@formbricks/types/js";
import { WEBSITE_SURVEYS_LOCAL_STORAGE_KEY } from "../../shared/constants";
import { Result, err, ok, wrapThrows } from "../../shared/errors";
export class WebsiteConfig {
private static instance: WebsiteConfig | undefined;
private config: TJsConfig | null = null;
private constructor() {
const localConfig = this.loadFromLocalStorage();
if (localConfig.ok) {
this.config = localConfig.value;
}
}
static getInstance(): WebsiteConfig {
if (!WebsiteConfig.instance) {
WebsiteConfig.instance = new WebsiteConfig();
}
return WebsiteConfig.instance;
}
public update(newConfig: TJsConfigUpdateInput): void {
if (newConfig) {
this.config = {
...this.config,
...newConfig,
status: {
value: newConfig.status?.value || "success",
expiresAt: newConfig.status?.expiresAt || null,
},
};
this.saveToLocalStorage();
}
}
public get(): TJsConfig {
if (!this.config) {
throw new Error("config is null, maybe the init function was not called?");
}
return this.config;
}
public loadFromLocalStorage(): Result<TJsConfig, Error> {
if (typeof window !== "undefined") {
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 TJsConfig;
// check if the config has expired
// TODO: Figure out the expiration logic
if (
parsedConfig.environmentState &&
parsedConfig.environmentState.expiresAt &&
new Date(parsedConfig.environmentState.expiresAt) <= new Date()
) {
return err(new Error("Config in local storage has expired"));
}
return ok(parsedConfig);
}
}
return err(new Error("No or invalid config in local storage"));
}
private saveToLocalStorage(): Result<void, Error> {
return wrapThrows(() =>
localStorage.setItem(WEBSITE_SURVEYS_LOCAL_STORAGE_KEY, JSON.stringify(this.config))
)();
}
// reset the config
public resetConfig(): Result<void, Error> {
this.config = null;
return wrapThrows(() => localStorage.removeItem(WEBSITE_SURVEYS_LOCAL_STORAGE_KEY))();
}
}
@@ -1,59 +0,0 @@
import {
addEnvironmentStateExpiryCheckListener,
clearEnvironmentStateExpiryCheckListener,
} from "../../shared/environmentState";
import {
addClickEventListener,
addExitIntentListener,
addPageUrlEventListeners,
addScrollDepthListener,
removeClickEventListener,
removeExitIntentListener,
removePageUrlEventListeners,
removeScrollDepthListener,
} from "../lib/noCodeActions";
import { WebsiteConfig } from "./config";
let areRemoveEventListenersAdded = false;
export const addEventListeners = (config: WebsiteConfig): void => {
addEnvironmentStateExpiryCheckListener("website", config);
clearEnvironmentStateExpiryCheckListener();
addPageUrlEventListeners();
addClickEventListener();
addExitIntentListener();
addScrollDepthListener();
};
export const addCleanupEventListeners = (): void => {
if (areRemoveEventListenersAdded) return;
window.addEventListener("beforeunload", () => {
clearEnvironmentStateExpiryCheckListener();
removePageUrlEventListeners();
removeClickEventListener();
removeExitIntentListener();
removeScrollDepthListener();
});
areRemoveEventListenersAdded = true;
};
export const removeCleanupEventListeners = (): void => {
if (!areRemoveEventListenersAdded) return;
window.removeEventListener("beforeunload", () => {
clearEnvironmentStateExpiryCheckListener();
removePageUrlEventListeners();
removeClickEventListener();
removeExitIntentListener();
removeScrollDepthListener();
});
areRemoveEventListenersAdded = false;
};
export const removeAllEventListeners = (): void => {
clearEnvironmentStateExpiryCheckListener();
removePageUrlEventListeners();
removeClickEventListener();
removeExitIntentListener();
removeScrollDepthListener();
removeCleanupEventListeners();
};
@@ -1,343 +0,0 @@
import type { TJsConfig, TJsWebsiteConfigInput, TJsWebsiteState } from "@formbricks/types/js";
import { WEBSITE_SURVEYS_LOCAL_STORAGE_KEY } from "../../shared/constants";
import { fetchEnvironmentState } from "../../shared/environmentState";
import {
ErrorHandler,
MissingFieldError,
MissingPersonError,
NetworkError,
NotInitializedError,
Result,
err,
okVoid,
wrapThrows,
} from "../../shared/errors";
import { Logger } from "../../shared/logger";
import { DEFAULT_PERSON_STATE_WEBSITE } from "../../shared/personState";
import { getIsDebug } from "../../shared/utils";
import { filterSurveys as filterPublicSurveys } from "../../shared/utils";
import { trackNoCodeAction } from "./actions";
import { WebsiteConfig } from "./config";
import { addCleanupEventListeners, addEventListeners, removeAllEventListeners } from "./eventListeners";
import { checkPageUrl } from "./noCodeActions";
import { addWidgetContainer, removeWidgetContainer, setIsSurveyRunning } from "./widget";
const logger = Logger.getInstance();
let isInitialized = false;
export const setIsInitialized = (value: boolean) => {
isInitialized = value;
};
const migrateLocalStorage = (): { changed: boolean; newState?: TJsConfig } => {
const oldConfig = localStorage.getItem(WEBSITE_SURVEYS_LOCAL_STORAGE_KEY);
let newWebsiteConfig: TJsConfig;
if (oldConfig) {
const parsedOldConfig = JSON.parse(oldConfig);
// if the old config follows the older structure, we need to migrate it
if (parsedOldConfig.state || parsedOldConfig.expiresAt) {
logger.debug("Migrating local storage");
const { apiHost, environmentId, state, expiresAt } = parsedOldConfig as {
apiHost: string;
environmentId: string;
state: TJsWebsiteState;
expiresAt: Date;
};
const { displays: displaysState, actionClasses, product, surveys, attributes } = state;
const responses = displaysState
.filter((display) => display.responded)
.map((display) => display.surveyId);
const displays = displaysState.map((display) => ({
surveyId: display.surveyId,
createdAt: display.createdAt,
}));
const lastDisplayAt = displaysState
? displaysState.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())[0]
.createdAt
: null;
newWebsiteConfig = {
apiHost,
environmentId,
environmentState: {
data: {
surveys,
actionClasses,
product,
},
expiresAt,
},
personState: {
expiresAt,
data: {
userId: null,
segments: [],
displays,
responses,
attributes: attributes ?? {},
lastDisplayAt,
},
},
filteredSurveys: surveys,
status: {
value: "success",
expiresAt: null,
},
};
logger.debug("Migrated local storage to new format");
return { changed: true, newState: newWebsiteConfig };
}
return { changed: false };
}
return { changed: false };
};
export const initialize = async (
configInput: TJsWebsiteConfigInput
): Promise<Result<void, MissingFieldError | NetworkError | MissingPersonError>> => {
const isDebug = getIsDebug();
if (isDebug) {
logger.configure({ logLevel: "debug" });
}
const { changed, newState } = migrateLocalStorage();
let websiteConfig = WebsiteConfig.getInstance();
// If the state was changed due to migration, reset and reinitialize the configuration
if (changed && newState) {
// The state exists in the local storage, so this should not fail
websiteConfig.resetConfig(); // Reset the configuration
// Re-fetch a new instance of WebsiteConfig after resetting
websiteConfig = WebsiteConfig.getInstance();
// Update the new instance with the migrated state
websiteConfig.update(newState);
}
if (isInitialized) {
logger.debug("Already initialized, skipping initialization.");
return okVoid();
}
let existingConfig: TJsConfig | undefined;
try {
existingConfig = websiteConfig.get();
logger.debug("Found existing configuration.");
} catch (e) {
logger.debug("No existing configuration found.");
}
// formbricks is in error state, skip initialization
if (existingConfig?.status?.value === "error") {
if (isDebug) {
logger.debug(
"Formbricks is in error state, but debug mode is active. Resetting config and continuing."
);
websiteConfig.resetConfig();
return okVoid();
}
logger.debug("Formbricks was set to an error state.");
if (existingConfig?.status?.expiresAt && new Date(existingConfig?.status?.expiresAt) > new Date()) {
logger.debug("Error state is not expired, skipping initialization");
return okVoid();
} else {
logger.debug("Error state is expired. Continue with initialization.");
}
}
ErrorHandler.getInstance().printStatus();
logger.debug("Start initialize");
if (!configInput.environmentId) {
logger.debug("No environmentId provided");
return err({
code: "missing_field",
field: "environmentId",
});
}
if (!configInput.apiHost) {
logger.debug("No apiHost provided");
return err({
code: "missing_field",
field: "apiHost",
});
}
logger.debug("Adding widget container to DOM");
addWidgetContainer();
if (
existingConfig &&
existingConfig.environmentId === configInput.environmentId &&
existingConfig.apiHost === configInput.apiHost &&
existingConfig.environmentState
) {
logger.debug("Configuration fits init parameters.");
if (existingConfig.environmentState.expiresAt < new Date()) {
logger.debug("Configuration expired.");
try {
// fetch the environment state
const environmentState = await fetchEnvironmentState(
{
apiHost: configInput.apiHost,
environmentId: configInput.environmentId,
},
"website"
);
// filter the surveys with the default person state
const filteredSurveys = filterPublicSurveys(
environmentState,
DEFAULT_PERSON_STATE_WEBSITE,
"website"
);
websiteConfig.update({
apiHost: configInput.apiHost,
environmentId: configInput.environmentId,
environmentState,
personState: DEFAULT_PERSON_STATE_WEBSITE,
filteredSurveys,
});
} catch (e) {
putFormbricksInErrorState(websiteConfig);
}
} else {
logger.debug("Configuration not expired. Extending expiration.");
websiteConfig.update(existingConfig);
}
} else {
logger.debug(
"No valid configuration found or it has been expired. Resetting config and creating new one."
);
websiteConfig.resetConfig();
logger.debug("Syncing.");
try {
const environmentState = await fetchEnvironmentState(
{
apiHost: configInput.apiHost,
environmentId: configInput.environmentId,
},
"website"
);
const filteredSurveys = filterPublicSurveys(environmentState, DEFAULT_PERSON_STATE_WEBSITE, "website");
websiteConfig.update({
apiHost: configInput.apiHost,
environmentId: configInput.environmentId,
environmentState,
personState: DEFAULT_PERSON_STATE_WEBSITE,
filteredSurveys,
});
} catch (e) {
handleErrorOnFirstInit();
}
if (configInput.attributes) {
const currentWebsiteConfig = websiteConfig.get();
websiteConfig.update({
...currentWebsiteConfig,
personState: {
...currentWebsiteConfig.personState,
data: {
...currentWebsiteConfig.personState.data,
attributes: { ...currentWebsiteConfig.personState.data.attributes, ...configInput.attributes },
},
},
});
}
// and track the new session event
await trackNoCodeAction("New Session");
}
logger.debug("Adding event listeners");
addEventListeners(websiteConfig);
addCleanupEventListeners();
setIsInitialized(true);
logger.debug("Initialized");
// check page url if initialized after page load
checkPageUrl();
return okVoid();
};
export const handleErrorOnFirstInit = () => {
if (getIsDebug()) {
logger.debug("Not putting formbricks in error state because debug mode is active (no error state)");
return;
}
const initialErrorConfig: Partial<TJsConfig> = {
status: {
value: "error",
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_SURVEYS_LOCAL_STORAGE_KEY, JSON.stringify(initialErrorConfig))
)();
throw new Error("Could not initialize formbricks");
};
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 = (): void => {
logger.debug("Deinitializing");
removeWidgetContainer();
setIsSurveyRunning(false);
removeAllEventListeners();
setIsInitialized(false);
};
export const putFormbricksInErrorState = (websiteConfig: WebsiteConfig): void => {
if (getIsDebug()) {
logger.debug("Not putting formbricks in error state because debug mode is active (no error state)");
return;
}
logger.debug("Putting formbricks in error state");
// change formbricks status to error
websiteConfig.update({
...websiteConfig.get(),
status: {
value: "error",
expiresAt: new Date(new Date().getTime() + 10 * 60000), // 10 minutes in the future
},
});
deinitalize();
};
@@ -1,189 +0,0 @@
import type { TActionClass } from "@formbricks/types/action-classes";
import { ErrorHandler, NetworkError, Result, err, match, okVoid } from "../../shared/errors";
import { Logger } from "../../shared/logger";
import { evaluateNoCodeConfigClick, handleUrlFilters } from "../../shared/utils";
import { trackNoCodeAction } from "./actions";
import { WebsiteConfig } from "./config";
const websiteConfig = WebsiteConfig.getInstance();
const logger = Logger.getInstance();
const errorHandler = ErrorHandler.getInstance();
// Event types for various listeners
const events = ["hashchange", "popstate", "pushstate", "replacestate", "load"];
// Page URL Event Handlers
let arePageUrlEventListenersAdded = false;
export const checkPageUrl = async (): Promise<Result<void, NetworkError>> => {
logger.debug(`Checking page url: ${window.location.href}`);
const actionClasses = websiteConfig.get().environmentState.data.actionClasses;
const noCodePageViewActionClasses = actionClasses.filter(
(action) => action.type === "noCode" && action.noCodeConfig?.type === "pageView"
);
for (const event of noCodePageViewActionClasses) {
const urlFilters = event.noCodeConfig?.urlFilters ?? [];
const isValidUrl = handleUrlFilters(urlFilters);
if (!isValidUrl) continue;
const trackResult = await trackNoCodeAction(event.name);
if (trackResult.ok !== true) return err(trackResult.error);
}
return okVoid();
};
const checkPageUrlWrapper = () => checkPageUrl();
export const addPageUrlEventListeners = (): void => {
if (typeof window === "undefined" || arePageUrlEventListenersAdded) return;
events.forEach((event) => window.addEventListener(event, checkPageUrlWrapper));
arePageUrlEventListenersAdded = true;
};
export const removePageUrlEventListeners = (): void => {
if (typeof window === "undefined" || !arePageUrlEventListenersAdded) return;
events.forEach((event) => window.removeEventListener(event, checkPageUrlWrapper));
arePageUrlEventListenersAdded = false;
};
// Click Event Handlers
let isClickEventListenerAdded = false;
const checkClickMatch = (event: MouseEvent) => {
const { environmentState } = websiteConfig.get();
if (!environmentState) return;
const { actionClasses = [] } = environmentState.data;
const noCodeClickActionClasses = actionClasses.filter(
(action) => action.type === "noCode" && action.noCodeConfig?.type === "click"
);
const targetElement = event.target as HTMLElement;
noCodeClickActionClasses.forEach((action: TActionClass) => {
if (evaluateNoCodeConfigClick(targetElement, action)) {
trackNoCodeAction(action.name).then((res) => {
match(
res,
(_value: unknown) => {},
(err: any) => errorHandler.handle(err)
);
});
}
});
};
const checkClickMatchWrapper = (e: MouseEvent) => checkClickMatch(e);
export const addClickEventListener = (): void => {
if (typeof window === "undefined" || isClickEventListenerAdded) return;
document.addEventListener("click", checkClickMatchWrapper);
isClickEventListenerAdded = true;
};
export const removeClickEventListener = (): void => {
if (!isClickEventListenerAdded) return;
document.removeEventListener("click", checkClickMatchWrapper);
isClickEventListenerAdded = false;
};
// Exit Intent Handlers
let isExitIntentListenerAdded = false;
const checkExitIntent = async (e: MouseEvent) => {
const actionClasses = websiteConfig.get().environmentState.data.actionClasses;
const noCodeExitIntentActionClasses = actionClasses.filter(
(action) => action.type === "noCode" && action.noCodeConfig?.type === "exitIntent"
);
if (e.clientY <= 0 && noCodeExitIntentActionClasses.length > 0) {
for (const event of noCodeExitIntentActionClasses) {
const urlFilters = event.noCodeConfig?.urlFilters ?? [];
const isValidUrl = handleUrlFilters(urlFilters);
if (!isValidUrl) continue;
const trackResult = await trackNoCodeAction(event.name);
if (trackResult.ok !== true) return err(trackResult.error);
}
}
};
const checkExitIntentWrapper = (e: MouseEvent) => checkExitIntent(e);
export const addExitIntentListener = (): void => {
if (typeof document !== "undefined" && !isExitIntentListenerAdded) {
document.querySelector("body")!.addEventListener("mouseleave", checkExitIntentWrapper);
isExitIntentListenerAdded = true;
}
};
export const removeExitIntentListener = (): void => {
if (isExitIntentListenerAdded) {
document.removeEventListener("mouseleave", checkExitIntentWrapper);
isExitIntentListenerAdded = false;
}
};
// Scroll Depth Handlers
let scrollDepthListenerAdded = false;
let scrollDepthTriggered = false;
const checkScrollDepth = async () => {
const scrollPosition = window.scrollY;
const windowSize = window.innerHeight;
const bodyHeight = document.documentElement.scrollHeight;
if (scrollPosition === 0) {
scrollDepthTriggered = false;
}
if (!scrollDepthTriggered && scrollPosition / (bodyHeight - windowSize) >= 0.5) {
scrollDepthTriggered = true;
const actionClasses = websiteConfig.get().environmentState.data.actionClasses;
const noCodefiftyPercentScrollActionClasses = actionClasses.filter(
(action) => action.type === "noCode" && action.noCodeConfig?.type === "fiftyPercentScroll"
);
for (const event of noCodefiftyPercentScrollActionClasses) {
const urlFilters = event.noCodeConfig?.urlFilters ?? [];
const isValidUrl = handleUrlFilters(urlFilters);
if (!isValidUrl) continue;
const trackResult = await trackNoCodeAction(event.name);
if (trackResult.ok !== true) return err(trackResult.error);
}
}
return okVoid();
};
const checkScrollDepthWrapper = () => checkScrollDepth();
export const addScrollDepthListener = (): void => {
if (typeof window !== "undefined" && !scrollDepthListenerAdded) {
if (document.readyState === "complete") {
window.addEventListener("scroll", checkScrollDepthWrapper);
} else {
window.addEventListener("load", () => {
window.addEventListener("scroll", checkScrollDepthWrapper);
});
}
scrollDepthListenerAdded = true;
}
};
export const removeScrollDepthListener = (): void => {
if (scrollDepthListenerAdded) {
window.removeEventListener("scroll", checkScrollDepthWrapper);
scrollDepthListenerAdded = false;
}
};
-290
View File
@@ -1,290 +0,0 @@
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 { TJsPersonState, TJsTrackProperties } from "@formbricks/types/js";
import { TResponseHiddenFieldValue, TResponseUpdate } from "@formbricks/types/responses";
import { TUploadFileConfig } from "@formbricks/types/storage";
import { TSurvey } from "@formbricks/types/surveys/types";
import { Logger } from "../../shared/logger";
import { filterSurveys as filterPublicSurveys } from "../../shared/utils";
import { getDefaultLanguageCode, getLanguageCode, handleHiddenFields } from "../../shared/utils";
import { WebsiteConfig } from "./config";
const containerId = "formbricks-website-container";
const websiteConfig = WebsiteConfig.getInstance();
const logger = Logger.getInstance();
let isSurveyRunning = false;
let setIsError = (_: boolean) => {};
let setIsResponseSendingFinished = (_: boolean) => {};
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,
properties?: TJsTrackProperties
): Promise<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 skipped based on displayPercentage.");
return; // skip displaying the survey
}
}
const hiddenFieldsObject: TResponseHiddenFieldValue = handleHiddenFields(
survey.hiddenFields,
properties?.hiddenFields
);
await renderWidget(survey, action, hiddenFieldsObject);
};
const renderWidget = async (
survey: TSurvey,
action?: string,
hiddenFields: TResponseHiddenFieldValue = {}
) => {
if (isSurveyRunning) {
logger.debug("A survey is already running. Skipping.");
return;
}
setIsSurveyRunning(true);
if (survey.delay) {
logger.debug(`Delaying survey by ${survey.delay} seconds.`);
}
const product = websiteConfig.get().environmentState.data.product;
const attributes = websiteConfig.get().personState.data.attributes;
const isMultiLanguageSurvey = survey.languages.length > 1;
let languageCode = "default";
if (isMultiLanguageSurvey && attributes) {
const displayLanguage = getLanguageCode(survey, attributes);
//if survey is not available in selected language, survey wont be shown
if (!displayLanguage) {
logger.debug("Survey not available in specified language.");
setIsSurveyRunning(true);
return;
}
languageCode = displayLanguage;
}
const surveyState = new SurveyState(survey.id, null, null);
const responseQueue = new ResponseQueue(
{
apiHost: websiteConfig.get().apiHost,
environmentId: websiteConfig.get().environmentId,
retryAttempts: 2,
onResponseSendingFailed: () => {
setIsError(true);
},
onResponseSendingFinished: () => {
setIsResponseSendingFinished(true);
},
},
surveyState
);
const productOverwrites = survey.productOverwrites ?? {};
const clickOutside = productOverwrites.clickOutsideClose ?? product.clickOutsideClose;
const darkOverlay = productOverwrites.darkOverlay ?? product.darkOverlay;
const placement = productOverwrites.placement ?? product.placement;
const isBrandingEnabled = product.inAppSurveyBranding;
const formbricksSurveys = await loadFormbricksSurveysExternally();
setTimeout(() => {
formbricksSurveys.renderSurveyModal({
survey,
isBrandingEnabled,
clickOutside,
darkOverlay,
languageCode,
placement,
styling: getStyling(product, survey),
getSetIsError: (f: (value: boolean) => void) => {
setIsError = f;
},
getSetIsResponseSendingFinished: (f: (value: boolean) => void) => {
setIsResponseSendingFinished = f;
},
onDisplay: async () => {
const api = new FormbricksAPI({
apiHost: websiteConfig.get().apiHost,
environmentId: websiteConfig.get().environmentId,
});
const res = await api.client.display.create({
surveyId: survey.id,
});
if (!res.ok) {
throw new Error("Could not create display");
}
const { id } = res.data;
const existingDisplays = websiteConfig.get().personState.data.displays;
const newDisplay = { surveyId: survey.id, createdAt: new Date() };
const displays = existingDisplays ? [...existingDisplays, newDisplay] : [newDisplay];
const previousConfig = websiteConfig.get();
const updatedPersonState: TJsPersonState = {
...previousConfig.personState,
data: {
...previousConfig.personState.data,
displays,
lastDisplayAt: new Date(),
},
};
const filteredSurveys = filterPublicSurveys(
previousConfig.environmentState,
updatedPersonState,
"website"
);
websiteConfig.update({
...previousConfig,
environmentState: previousConfig.environmentState,
personState: updatedPersonState,
filteredSurveys,
});
surveyState.updateDisplayId(id);
responseQueue.updateSurveyState(surveyState);
},
onResponse: (responseUpdate: TResponseUpdate) => {
const displays = websiteConfig.get().personState.data.displays;
const lastDisplay = displays && displays[displays.length - 1];
if (!lastDisplay) {
throw new Error("No lastDisplay found");
}
const isNewResponse = surveyState.responseId === null;
responseQueue.updateSurveyState(surveyState);
responseQueue.add({
data: responseUpdate.data,
ttc: responseUpdate.ttc,
finished: responseUpdate.finished,
language:
responseUpdate.language === "default" ? getDefaultLanguageCode(survey) : responseUpdate.language,
meta: {
url: window.location.href,
action,
},
variables: responseUpdate.variables,
hiddenFields,
displayId: surveyState.displayId,
});
if (isNewResponse) {
const responses = websiteConfig.get().personState.data.responses;
const newPersonState: TJsPersonState = {
...websiteConfig.get().personState,
data: {
...websiteConfig.get().personState.data,
responses: [...responses, surveyState.surveyId],
},
};
const filteredSurveys = filterPublicSurveys(
websiteConfig.get().environmentState,
newPersonState,
"website"
);
websiteConfig.update({
...websiteConfig.get(),
environmentState: websiteConfig.get().environmentState,
personState: newPersonState,
filteredSurveys,
});
}
},
onClose: closeSurvey,
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(
{
name: file.name,
type: file.type,
base64: file.base64,
},
params
);
},
onRetry: () => {
setIsError(false);
responseQueue.processQueue();
},
hiddenFieldsRecord: hiddenFields,
});
}, survey.delay * 1000);
};
export const closeSurvey = async (): Promise<void> => {
// remove container element from DOM
removeWidgetContainer();
addWidgetContainer();
const { environmentState, personState } = websiteConfig.get();
const filteredSurveys = filterPublicSurveys(environmentState, personState, "website");
websiteConfig.update({
...websiteConfig.get(),
environmentState,
personState,
filteredSurveys,
});
setIsSurveyRunning(false);
return;
};
export const addWidgetContainer = (): void => {
const containerElement = document.createElement("div");
containerElement.id = containerId;
document.body.appendChild(containerElement);
};
export const removeWidgetContainer = (): void => {
document.getElementById(containerId)?.remove();
};
const loadFormbricksSurveysExternally = (): Promise<typeof window.formbricksSurveys> => {
return new Promise((resolve, reject) => {
if (window.formbricksSurveys) {
resolve(window.formbricksSurveys);
} else {
const script = document.createElement("script");
script.src = `${websiteConfig.get().apiHost}/api/packages/surveys`;
script.async = true;
script.onload = () => resolve(window.formbricksSurveys);
script.onerror = (error) => {
console.error("Failed to load Formbricks Surveys library:", error);
reject(error);
};
document.head.appendChild(script);
}
});
};
@@ -16,10 +16,10 @@ const config = () => {
minify: "terser",
sourcemap: true,
lib: {
entry: resolve(__dirname, "src/app/index.ts"),
entry: resolve(__dirname, "src/index.ts"),
name: "formbricks",
formats: ["umd"],
fileName: "app",
fileName: "index",
},
},
plugins: [
-34
View File
@@ -1,34 +0,0 @@
import { resolve } from "path";
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
import webPackageJson from "../../apps/web/package.json";
const config = () => {
return defineConfig({
define: {
"import.meta.env.VERSION": JSON.stringify(webPackageJson.version),
},
build: {
rollupOptions: {
output: { inlineDynamicImports: true },
},
emptyOutDir: false, // keep the dist folder to avoid errors with pnpm go when folder is empty during build
minify: "terser",
sourcemap: true,
lib: {
entry: resolve(__dirname, "src/website/index.ts"),
name: "formbricks",
formats: ["umd"],
fileName: "website",
},
},
plugins: [
dts({
rollupTypes: true,
bundledPackages: ["@formbricks/api", "@formbricks/types"],
}),
],
});
};
export default config;
+2 -2
View File
@@ -2,12 +2,12 @@
<script type="text/javascript">
!(function () {
var t = document.createElement("script");
(t.type = "text/javascript"), (t.async = !0), (t.src = "http://localhost:3000/api/packages/app");
(t.type = "text/javascript"), (t.async = !0), (t.src = "http://localhost:3000/api/packages/js");
var e = document.getElementsByTagName("script")[0];
e.parentNode.insertBefore(t, e),
setTimeout(function () {
formbricks.init({
environmentId: "cm1qbbvo8000c5ij3dt7qmyn6",
environmentId: "cm20dunwt0005a9yhllps6eom",
userId: "RANDOM_USER_ID",
apiHost: "http://localhost:3000",
});
+7 -18
View File
@@ -1,8 +1,8 @@
{
"name": "@formbricks/js",
"license": "MIT",
"version": "2.2.0",
"description": "Formbricks-js allows you to connect your app to Formbricks, display surveys and trigger events.",
"version": "3.0.0",
"description": "Formbricks-js allows you to connect your index to Formbricks, display surveys and trigger events.",
"homepage": "https://formbricks.com",
"repository": {
"type": "git",
@@ -19,25 +19,14 @@
],
"type": "module",
"exports": {
"./app": {
"import": "./dist/app.js",
"require": "./dist/app.cjs",
"types": "./dist/app.d.ts"
},
"./website": {
"import": "./dist/website.js",
"require": "./dist/website.cjs",
"types": "./dist/website.d.ts"
},
"./*": "./dist/*"
"import": "./dist/index.js",
"require": "./dist/index.cjs",
"types": "./dist/index.d.ts"
},
"typesVersions": {
"*": {
"app": [
"./dist/app.d.ts"
],
"website": [
"./dist/website.d.ts"
"*": [
"./dist/index.d.ts"
]
}
},
-23
View File
@@ -1,23 +0,0 @@
/* eslint-disable @typescript-eslint/no-unsafe-assignment --
* Required because it doesn't work without building otherwise
*/
import { type TFormbricksApp } from "@formbricks/js-core/app";
import { type TFormbricksWebsite } from "@formbricks/js-core/website";
import { loadFormbricksToProxy } from "./shared/load-formbricks";
declare global {
interface Window {
formbricks: TFormbricksApp | TFormbricksWebsite | undefined;
}
}
const formbricksProxyHandler: ProxyHandler<TFormbricksApp> = {
get(_target, prop, _receiver) {
return (...args: unknown[]) => loadFormbricksToProxy(prop as string, "app", ...args);
},
};
const formbricksApp: TFormbricksApp = new Proxy({} as TFormbricksApp, formbricksProxyHandler);
// eslint-disable-next-line import/no-default-export -- Required for UMD
export default formbricksApp;
+20
View File
@@ -0,0 +1,20 @@
import { type TFormbricksApp } from "@formbricks/js-core";
import { loadFormbricksToProxy } from "./lib/load-formbricks";
declare global {
interface Window {
formbricks: TFormbricksApp | undefined;
}
}
const formbricksProxyHandler: ProxyHandler<TFormbricksApp> = {
get(_target, prop, _receiver) {
return (...args: unknown[]) => loadFormbricksToProxy(prop as string, ...args);
},
};
const formbricksApp: TFormbricksApp = new Proxy({} as TFormbricksApp, formbricksProxyHandler);
// eslint-disable-next-line import/no-default-export -- Required for UMD
export default formbricksApp;
@@ -8,13 +8,13 @@ let isInitializing = false;
let isInitialized = false;
// Load the SDK, return the result
const loadFormbricksSDK = async (apiHostParam: string, sdkType: "app" | "website"): Promise<Result<void>> => {
const loadFormbricksSDK = async (apiHostParam: string): Promise<Result<void>> => {
if (!window.formbricks) {
const res = await fetch(`${apiHostParam}/api/packages/${sdkType}`);
const res = await fetch(`${apiHostParam}/api/packages/js`);
// Failed to fetch the app package
if (!res.ok) {
return { ok: false, error: new Error(`Failed to load Formbricks ${sdkType} SDK`) };
return { ok: false, error: new Error(`Failed to load Formbricks SDK`) };
}
const sdkScript = await res.text();
@@ -33,7 +33,7 @@ const loadFormbricksSDK = async (apiHostParam: string, sdkType: "app" | "website
setTimeout(() => {
clearInterval(checkInterval);
reject(new Error(`Formbricks ${sdkType} SDK loading timed out`));
reject(new Error(`Formbricks SDK loading timed out`));
}, 10000);
});
@@ -45,7 +45,7 @@ const loadFormbricksSDK = async (apiHostParam: string, sdkType: "app" | "website
return {
ok: false,
error: new Error(err.message ?? `Failed to load Formbricks ${sdkType} SDK`),
error: new Error(err.message ?? `Failed to load Formbricks SDK`),
};
}
}
@@ -55,11 +55,8 @@ const loadFormbricksSDK = async (apiHostParam: string, sdkType: "app" | "website
const functionsToProcess: { prop: string; args: unknown[] }[] = [];
export const loadFormbricksToProxy = async (
prop: string,
sdkType: "app" | "website",
...args: unknown[]
): Promise<void> => {
export const loadFormbricksToProxy = async (prop: string, ...args: unknown[]): Promise<void> => {
console.log(args);
// all of this should happen when not initialized:
if (!isInitialized) {
if (prop === "init") {
@@ -72,7 +69,7 @@ export const loadFormbricksToProxy = async (
isInitializing = true;
const apiHost = (args[0] as { apiHost: string }).apiHost;
const loadSDKResult = await loadFormbricksSDK(apiHost, sdkType);
const loadSDKResult = await loadFormbricksSDK(apiHost);
if (loadSDKResult.ok) {
if (window.formbricks) {
-23
View File
@@ -1,23 +0,0 @@
/* eslint-disable @typescript-eslint/no-unsafe-assignment --
* Required because it doesn't work without building otherwise
*/
import { type TFormbricksApp } from "@formbricks/js-core/app";
import { type TFormbricksWebsite } from "@formbricks/js-core/website";
import { loadFormbricksToProxy } from "./shared/load-formbricks";
declare global {
interface Window {
formbricks: TFormbricksApp | TFormbricksWebsite | undefined;
}
}
const formbricksProxyHandler: ProxyHandler<TFormbricksWebsite> = {
get(_target, prop, _receiver) {
return (...args: unknown[]) => loadFormbricksToProxy(prop as string, "website", ...args);
},
};
const formbricksWebsite: TFormbricksWebsite = new Proxy({} as TFormbricksWebsite, formbricksProxyHandler);
// eslint-disable-next-line import/no-default-export -- Required for UMD
export default formbricksWebsite;
+2 -4
View File
@@ -10,12 +10,10 @@ const config = () => {
sourcemap: true,
lib: {
// Could also be a dictionary or array of multiple entry points
entry: {
app: resolve(__dirname, "src/app.ts"),
website: resolve(__dirname, "src/website.ts"),
},
entry: resolve(__dirname, "src/index.ts"),
name: "formbricksJsWrapper",
formats: ["es", "cjs"],
fileName: "index",
},
},
plugins: [dts({ rollupTypes: true, bundledPackages: ["@formbricks/js-core"] })],
-1
View File
@@ -168,7 +168,6 @@ export const createEnvironment = async (
type: environmentInput.type || "development",
product: { connect: { id: productId } },
appSetupCompleted: environmentInput.appSetupCompleted || false,
websiteSetupCompleted: environmentInput.websiteSetupCompleted || false,
actionClasses: {
create: [
{
-1
View File
@@ -18,5 +18,4 @@ export const mockEnvironment: TEnvironment = {
type: "production",
productId: mockId,
appSetupCompleted: false,
websiteSetupCompleted: false,
};
@@ -95,7 +95,6 @@ export const mockEnvironment: TEnvironment = {
type: "production",
productId: mockId,
appSetupCompleted: false,
websiteSetupCompleted: false,
};
export const mockUser: TUser = {
@@ -237,7 +236,7 @@ export const mockSyncSurveyOutput: SurveyMock = {
};
export const mockSurveyOutput: SurveyMock = {
type: "website",
type: "link",
status: "inProgress",
displayOption: "respondMultiple",
triggers: [{ actionClass: mockActionClass }],
@@ -256,7 +255,6 @@ export const mockSurveyOutput: SurveyMock = {
};
export const createSurveyInput: TSurveyCreateInput = {
type: "website",
status: "inProgress",
displayOption: "respondMultiple",
triggers: [{ actionClass: mockActionClass }],
@@ -264,7 +262,7 @@ export const createSurveyInput: TSurveyCreateInput = {
};
export const updateSurveyInput: TSurvey = {
type: "website",
type: "link",
status: "inProgress",
displayOption: "respondMultiple",
triggers: [{ actionClass: mockActionClass }],
+3 -3
View File
@@ -1,12 +1,12 @@
import React, { useCallback, useEffect, useSyncExternalStore } from "react";
import { type TJsAppConfigInput } from "@formbricks/types/js";
import { Logger } from "../../js-core/src/shared/logger";
import { type TJsReactNativeConfigInput } from "@formbricks/types/js";
import { Logger } from "../../js-core/src/lib/logger";
import { init } from "./lib";
import { SurveyStore } from "./lib/survey-store";
import { SurveyWebView } from "./survey-web-view";
interface FormbricksProps {
initConfig: TJsAppConfigInput;
initConfig: TJsReactNativeConfigInput;
}
const surveyStore = SurveyStore.getInstance();
const logger = Logger.getInstance();
+3 -3
View File
@@ -5,9 +5,9 @@ import {
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";
} from "../../../js-core/src/lib/errors";
import { Logger } from "../../../js-core/src/lib/logger";
import { shouldDisplayBasedOnPercentage } from "../../../js-core/src/lib/utils";
import { appConfig } from "./config";
import { SurveyStore } from "./survey-store";
+1 -1
View File
@@ -3,7 +3,7 @@ import { FormbricksAPI } from "@formbricks/api";
import type { TAttributes } from "@formbricks/types/attributes";
import { type Result, err, ok } from "@formbricks/types/error-handlers";
import type { NetworkError } from "@formbricks/types/errors";
import { Logger } from "../../../js-core/src/shared/logger";
import { Logger } from "../../../js-core/src/lib/logger";
import { appConfig } from "./config";
const logger = Logger.getInstance();
@@ -1,12 +1,10 @@
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 { ErrorHandler, type Result } from "../../../js-core/src/lib/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[];
}[] = [];
@@ -15,12 +13,11 @@ export class CommandQueue {
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 });
this.queue.push({ command, checkInitialized: shouldCheckInitialized, commandArgs: args });
if (!this.running) {
this.commandPromise = new Promise((resolve) => {
+1 -5
View File
@@ -2,17 +2,13 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import { type Result, err, ok, wrapThrowsAsync } from "@formbricks/types/error-handlers";
import type { TJsAppConfigUpdateInput, TJsRNConfig } from "@formbricks/types/js";
import { RN_ASYNC_STORAGE_KEY } from "../../../js-core/src/shared/constants";
// LocalStorage implementation - default
import { RN_ASYNC_STORAGE_KEY } from "../../../js-core/src/lib/constants";
export class RNConfig {
private static instance: RNConfig | undefined;
private config: TJsRNConfig | null = null;
private constructor() {
// const localConfig = this.loadFromStorage();
this.loadFromStorage()
.then((localConfig) => {
if (localConfig.ok) {
+6 -6
View File
@@ -1,6 +1,6 @@
import { type TJsAppConfigInput } from "@formbricks/types/js";
import { ErrorHandler } from "../../../js-core/src/shared/errors";
import { Logger } from "../../../js-core/src/shared/logger";
import { type TJsReactNativeConfigInput } from "@formbricks/types/js";
import { ErrorHandler } from "../../../js-core/src/lib/errors";
import { Logger } from "../../../js-core/src/lib/logger";
import { trackCodeAction } from "./actions";
import { CommandQueue } from "./command-queue";
import { initialize } from "./initialize";
@@ -9,13 +9,13 @@ const logger = Logger.getInstance();
logger.debug("Create command queue");
const queue = new CommandQueue();
export const init = async (initConfig: TJsAppConfigInput): Promise<void> => {
export const init = async (initConfig: TJsReactNativeConfigInput): Promise<void> => {
ErrorHandler.init(initConfig.errorHandler);
queue.add("app", initialize, false, initConfig);
queue.add(initialize, false, initConfig);
await queue.wait();
};
export const track = async (name: string, properties = {}): Promise<void> => {
queue.add<any>("app", trackCodeAction, true, name, properties);
queue.add<any>(trackCodeAction, true, name, properties);
await queue.wait();
};
+4 -4
View File
@@ -1,5 +1,5 @@
import { type TAttributes } from "@formbricks/types/attributes";
import { type TJsAppConfigInput, type TJsRNConfig } from "@formbricks/types/js";
import { type TJsRNConfig, type TJsReactNativeConfigInput } from "@formbricks/types/js";
import {
ErrorHandler,
type MissingFieldError,
@@ -9,8 +9,8 @@ import {
type Result,
err,
okVoid,
} from "../../../js-core/src/shared/errors";
import { Logger } from "../../../js-core/src/shared/logger";
} from "../../../js-core/src/lib/errors";
import { Logger } from "../../../js-core/src/lib/logger";
import { trackAction } from "./actions";
import { updateAttributes } from "./attributes";
import { appConfig } from "./config";
@@ -24,7 +24,7 @@ export const setIsInitialize = (state: boolean): void => {
};
export const initialize = async (
c: TJsAppConfigInput
c: TJsReactNativeConfigInput
): Promise<Result<void, MissingFieldError | NetworkError | MissingPersonError>> => {
if (isInitialized) {
logger.debug("Already initialized, skipping initialization.");
+2 -2
View File
@@ -1,5 +1,5 @@
import { type NetworkError, type Result, err, okVoid } from "../../../js-core/src/shared/errors";
import { Logger } from "../../../js-core/src/shared/logger";
import { type NetworkError, type Result, err, okVoid } from "../../../js-core/src/lib/errors";
import { Logger } from "../../../js-core/src/lib/logger";
import { appConfig } from "./config";
import { deinitalize, initialize } from "./initialize";
+1 -1
View File
@@ -5,7 +5,7 @@ import type { TAttributes } from "@formbricks/types/attributes";
import { type Result, err, ok } from "@formbricks/types/error-handlers";
import type { NetworkError } from "@formbricks/types/errors";
import type { TJsAppState, TJsAppStateSync, TJsRNSyncParams } from "@formbricks/types/js";
import { Logger } from "../../../js-core/src/shared/logger";
import { Logger } from "../../../js-core/src/lib/logger";
import type { RNConfig } from "./config";
const logger = Logger.getInstance();
@@ -14,8 +14,8 @@ 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 { Logger } from "../../js-core/src/shared/logger";
import { getDefaultLanguageCode, getLanguageCode } from "../../js-core/src/shared/utils";
import { Logger } from "../../js-core/src/lib/logger";
import { getDefaultLanguageCode, getLanguageCode } from "../../js-core/src/lib/utils";
import { appConfig } from "./lib/config";
import { SurveyStore } from "./lib/survey-store";
import { sync } from "./lib/sync";
@@ -13,7 +13,7 @@ interface AutoCloseProps {
export const AutoCloseWrapper = ({ survey, onClose, children, offset }: AutoCloseProps) => {
const [countDownActive, setCountDownActive] = useState(true);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const isAppSurvey = survey.type === "app" || survey.type === "website";
const isAppSurvey = survey.type === "app";
const showAutoCloseProgressBar = countDownActive && isAppSurvey && offset === 0;
const startCountdown = () => {
-3
View File
@@ -7,7 +7,6 @@ export const ZEnvironment = z.object({
type: z.enum(["development", "production"]),
productId: z.string(),
appSetupCompleted: z.boolean(),
websiteSetupCompleted: z.boolean(),
});
export type TEnvironment = z.infer<typeof ZEnvironment>;
@@ -22,13 +21,11 @@ export const ZEnvironmentUpdateInput = z.object({
type: z.enum(["development", "production"]),
productId: z.string(),
appSetupCompleted: z.boolean(),
websiteSetupCompleted: z.boolean(),
});
export const ZEnvironmentCreateInput = z.object({
type: z.enum(["development", "production"]).optional(),
appSetupCompleted: z.boolean().optional(),
websiteSetupCompleted: z.boolean().optional(),
});
export type TEnvironmentCreateInput = z.infer<typeof ZEnvironmentCreateInput>;
+6 -7
View File
@@ -174,15 +174,18 @@ export const ZJsWebsiteConfigInput = z.object({
export type TJsWebsiteConfigInput = z.infer<typeof ZJsWebsiteConfigInput>;
export const ZJsAppConfigInput = z.object({
export const ZJsConfigInput = z.object({
environmentId: z.string().cuid2(),
apiHost: z.string(),
errorHandler: z.function().args(z.any()).returns(z.void()).optional(),
userId: z.string(),
userId: z.string().optional(),
attributes: z.record(z.string()).optional(),
});
export type TJsAppConfigInput = z.infer<typeof ZJsAppConfigInput>;
export type TJsConfigInput = z.infer<typeof ZJsConfigInput>;
export const ZJsReactNativeConfigInput = ZJsConfigInput.omit({ userId: true }).extend({ userId: z.string() });
export type TJsReactNativeConfigInput = z.infer<typeof ZJsReactNativeConfigInput>;
export const ZJsPeopleUserIdInput = z.object({
environmentId: z.string().cuid2(),
@@ -234,10 +237,6 @@ export const ZJsWebsiteSyncParams = ZJsPersonSyncParams.omit({ userId: true });
export type TJsWebsiteSyncParams = z.infer<typeof ZJsWebsiteSyncParams>;
export const ZJsPackageType = z.union([z.literal("app"), z.literal("website")]);
export type TJsPackageType = z.infer<typeof ZJsPackageType>;
export const ZJsTrackProperties = z.object({
hiddenFields: ZResponseHiddenFieldValue.optional(),
});
+1 -1
View File
@@ -683,7 +683,7 @@ export const ZSurveyDisplayOption = z.enum([
export type TSurveyDisplayOption = z.infer<typeof ZSurveyDisplayOption>;
export const ZSurveyType = z.enum(["link", "app", "website"]);
export const ZSurveyType = z.enum(["link", "app"]);
export type TSurveyType = z.infer<typeof ZSurveyType>;
@@ -9,7 +9,6 @@ type EmptySpaceFillerProps = {
environment: TEnvironment;
noWidgetRequired?: boolean;
emptyMessage?: string;
widgetSetupCompleted?: boolean;
};
export const EmptySpaceFiller = ({
@@ -17,7 +16,6 @@ export const EmptySpaceFiller = ({
environment,
noWidgetRequired,
emptyMessage,
widgetSetupCompleted = false,
}: EmptySpaceFillerProps) => {
if (type === "table") {
return (
@@ -25,7 +23,7 @@ export const EmptySpaceFiller = ({
<div className="w-full space-y-3">
<div className="h-16 w-full rounded-lg bg-slate-50"></div>
<div className="flex h-16 w-full flex-col items-center justify-center rounded-lg bg-slate-50 text-slate-700 transition-all duration-300 ease-in-out hover:bg-slate-100">
{!widgetSetupCompleted && !noWidgetRequired && (
{!environment.appSetupCompleted && !noWidgetRequired && (
<Link
className="flex w-full items-center justify-center"
href={`/environments/${environment.id}/product/app-connection`}>
@@ -34,7 +32,8 @@ export const EmptySpaceFiller = ({
</span>
</Link>
)}
{((widgetSetupCompleted || noWidgetRequired) && emptyMessage) || "Waiting for a response 🧘‍♂️"}
{((environment.appSetupCompleted || noWidgetRequired) && emptyMessage) ||
"Waiting for a response 🧘‍♂️"}
</div>
<div className="h-16 w-full rounded-lg bg-slate-50"></div>
@@ -53,7 +52,7 @@ export const EmptySpaceFiller = ({
<div className="space-y-4">
<div className="h-12 w-full rounded-full bg-slate-100"></div>
<div className="flex h-12 w-full items-center justify-center rounded-full bg-slate-50 text-sm text-slate-500 hover:bg-slate-100">
{!widgetSetupCompleted && !noWidgetRequired && (
{!environment.appSetupCompleted && !noWidgetRequired && (
<Link
className="flex h-full w-full items-center justify-center"
href={`/environments/${environment.id}/product/app-connection`}>
@@ -62,7 +61,7 @@ export const EmptySpaceFiller = ({
</span>
</Link>
)}
{(widgetSetupCompleted || noWidgetRequired) && (
{(environment.appSetupCompleted || noWidgetRequired) && (
<span className="bg-light-background-primary-500 text-center">
{emptyMessage ?? "Waiting for a response"} 🧘
</span>
@@ -84,7 +83,7 @@ export const EmptySpaceFiller = ({
<div className="space-y-4">
<div className="h-12 w-full rounded-full bg-slate-100"></div>
<div className="flex h-12 w-full items-center justify-center rounded-full bg-slate-50 text-sm text-slate-500 hover:bg-slate-100">
{!widgetSetupCompleted && !noWidgetRequired && (
{!environment.appSetupCompleted && !noWidgetRequired && (
<Link
className="flex h-full w-full items-center justify-center"
href={`/environments/${environment.id}/product/app-connection`}>
@@ -93,7 +92,7 @@ export const EmptySpaceFiller = ({
</span>
</Link>
)}
{(widgetSetupCompleted || noWidgetRequired) && (
{(environment.appSetupCompleted || noWidgetRequired) && (
<span className="text-center">Tag a submission to find your list of tags here.</span>
)}
</div>
@@ -132,7 +131,7 @@ export const EmptySpaceFiller = ({
<div className="space-y-4">
<div className="h-12 w-full rounded-full bg-slate-100"></div>
<div className="flex h-12 w-full items-center justify-center rounded-full bg-slate-50 text-sm text-slate-500 hover:bg-slate-100">
{!widgetSetupCompleted && !noWidgetRequired && (
{!environment.appSetupCompleted && !noWidgetRequired && (
<Link
className="flex h-full w-full items-center justify-center"
href={`/environments/${environment.id}/product/app-connection`}>
@@ -141,7 +140,7 @@ export const EmptySpaceFiller = ({
</span>
</Link>
)}
{(widgetSetupCompleted || noWidgetRequired) && (
{(environment.appSetupCompleted || noWidgetRequired) && (
<span className="text-center">Waiting for a response 🧘</span>
)}
</div>
@@ -72,7 +72,6 @@ export const PreviewSurvey = ({
const [isFullScreenPreview, setIsFullScreenPreview] = useState(false);
const [appSetupCompleted, setAppSetupCompleted] = useState(false);
const [websiteSetupCompleted, setWebsiteSetupCompleted] = useState(false);
const [previewMode, setPreviewMode] = useState("desktop");
const [previewPosition, setPreviewPosition] = useState("relative");
@@ -122,7 +121,7 @@ export const PreviewSurvey = ({
const darkOverlay = surveyDarkOverlay ?? product.darkOverlay;
const clickOutsideClose = surveyClickOutsideClose ?? product.clickOutsideClose;
const widgetSetupCompleted = appSetupCompleted || websiteSetupCompleted;
const widgetSetupCompleted = appSetupCompleted;
const styling: TSurveyStyling | TProductStyling = useMemo(() => {
// allow style overwrite is disabled from the product
@@ -167,7 +166,7 @@ export const PreviewSurvey = ({
const onFinished = () => {
// close modal if there are no questions left
if ((survey.type === "website" || survey.type === "app") && survey.endings.length === 0) {
if (survey.type === "app" && survey.endings.length === 0) {
setIsModalOpen(false);
setTimeout(() => {
setQuestionId(survey.questions[0]?.id);
@@ -198,7 +197,6 @@ export const PreviewSurvey = ({
useEffect(() => {
if (environment) {
setAppSetupCompleted(environment.appSetupCompleted);
setWebsiteSetupCompleted(environment.websiteSetupCompleted);
}
}, [environment]);
@@ -176,9 +176,9 @@ export const SingleResponseCardHeader = ({
{pageType === "people" && (
<div className="flex items-center justify-center space-x-2 rounded-full bg-slate-100 p-1 px-2 text-sm text-slate-600">
{(survey.type === "link" ||
environment.appSetupCompleted ||
environment.websiteSetupCompleted) && <SurveyStatusIndicator status={survey.status} />}
{(survey.type === "link" || environment.appSetupCompleted) && (
<SurveyStatusIndicator status={survey.status} />
)}
<Link
className="hover:underline"
href={`/environments/${environmentId}/surveys/${survey.id}/summary`}>
@@ -66,13 +66,11 @@ export const CopySurveyForm = ({
<div className="space-y-8 pb-12">
{formFields.fields.map((field, productIndex) => {
const product = defaultProducts.find((product) => product.id === field.product);
const isDisabled = survey.type !== "link" && product?.config.channel !== survey.type;
return (
<div key={product?.id}>
<div className="flex flex-col gap-4">
<TooltipRenderer
shouldRender={isDisabled}
tooltipContent={
<span>
This product is not compatible with the survey type. Please select a different
@@ -80,10 +78,7 @@ export const CopySurveyForm = ({
</span>
}>
<div className="w-fit">
<p className="text-base font-semibold text-slate-900">
{product?.name}
{isDisabled && <span className="ml-2 mr-11 text-sm text-gray-500">(Disabled)</span>}
</p>
<p className="text-base font-semibold text-slate-900">{product?.name}</p>
</div>
</TooltipRenderer>
@@ -103,11 +98,6 @@ export const CopySurveyForm = ({
<Checkbox
{...field}
type="button"
onClick={(e) => {
if (isDisabled) {
e.preventDefault();
}
}}
onCheckedChange={() => {
if (field.value.includes(environment.id)) {
field.onChange(
@@ -118,7 +108,6 @@ export const CopySurveyForm = ({
}
}}
className="mr-2 h-4 w-4 appearance-none border-gray-300 checked:border-transparent checked:bg-slate-500 checked:after:bg-slate-500 checked:hover:bg-slate-500 focus:ring-2 focus:ring-slate-500 focus:ring-opacity-50"
disabled={isDisabled}
id={environment.id}
/>
<Label htmlFor={environment.id}>
@@ -1,4 +1,4 @@
import { Code, EarthIcon, Link2Icon } from "lucide-react";
import { Code, Link2Icon } from "lucide-react";
import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
import toast from "react-hot-toast";
@@ -6,7 +6,7 @@ import { getFormattedErrorMessage } from "@formbricks/lib/actionClient/helper";
import { cn } from "@formbricks/lib/cn";
import { convertDateString, timeSince } from "@formbricks/lib/time";
import { TEnvironment } from "@formbricks/types/environment";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TSurvey, TSurveyType } from "@formbricks/types/surveys/types";
import { SurveyStatusIndicator } from "../../SurveyStatusIndicator";
import { generateSingleUseIdAction } from "../actions";
import { SurveyDropDownMenu } from "./SurveyDropdownMenu";
@@ -70,7 +70,7 @@ export const SurveyCard = ({
: `/environments/${environment.id}/surveys/${survey.id}/summary`;
}, [survey.status, survey.id, environment.id]);
const SurveyTypeIndicator = ({ type }: { type: TSurvey["type"] }) => (
const SurveyTypeIndicator = ({ type }: { type: TSurveyType }) => (
<div className="flex items-center space-x-2 text-sm text-slate-600">
{type === "app" && (
<>
@@ -79,13 +79,6 @@ export const SurveyCard = ({
</>
)}
{type === "website" && (
<>
<EarthIcon className="h-4 w-4" />
<span> Website</span>
</>
)}
{type === "link" && (
<>
<Link2Icon className="h-4 w-4" />
@@ -115,7 +115,7 @@ export const SurveyFilters = ({
};
const handleTypeChange = (value: string) => {
if (value === "link" || value === "app" || value === "website") {
if (value === "link" || value === "app") {
if (type.includes(value)) {
setSurveyFilters((prev) => ({ ...prev, type: prev.type.filter((v) => v !== value) }));
} else {
@@ -21,7 +21,9 @@ export const TemplateFilters = ({
newFilter[index] = filterValue;
setSelectedFilter(newFilter);
};
const allFilters = [channelMapping, industryMapping, roleMapping];
return (
<div className="mb-6 gap-3">
{allFilters.map((filters, index) => {
@@ -1,8 +1,7 @@
import { SplitIcon } from "lucide-react";
import { useMemo } from "react";
import { cn } from "@formbricks/lib/cn";
import { TProductConfigIndustry } from "@formbricks/types/product";
import { TSurveyType } from "@formbricks/types/surveys/types";
import { TProductConfigChannel, TProductConfigIndustry } from "@formbricks/types/product";
import { TTemplate, TTemplateFilter, TTemplateRole } from "@formbricks/types/templates";
import { TooltipRenderer } from "../../Tooltip";
import { channelMapping, industryMapping, roleMapping } from "../lib/utils";
@@ -12,6 +11,8 @@ interface TemplateTagsProps {
selectedFilter: TTemplateFilter[];
}
type NonNullabeChannel = NonNullable<TProductConfigChannel>;
const getRoleBasedStyling = (role: TTemplateRole | undefined): string => {
switch (role) {
case "productManager":
@@ -27,9 +28,9 @@ const getRoleBasedStyling = (role: TTemplateRole | undefined): string => {
}
};
const getChannelTag = (channels: TSurveyType[] | undefined): string | undefined => {
const getChannelTag = (channels: NonNullabeChannel[] | undefined): string | undefined => {
if (!channels) return undefined;
const getLabel = (channelValue: TSurveyType) =>
const getLabel = (channelValue: NonNullabeChannel) =>
channelMapping.find((channel) => channel.value === channelValue)?.label;
const labels = channels.map((channel) => getLabel(channel)).sort();
@@ -60,7 +61,6 @@ export const TemplateTags = ({ template, selectedFilter }: TemplateTagsProps) =>
);
const channelTag = useMemo(() => getChannelTag(template.channels), [template.channels]);
const getIndustryTag = (industries: TProductConfigIndustry[] | undefined): string | undefined => {
// if user selects an industry e.g. eCommerce than the tag should not say "Multiple industries" anymore but "E-Commerce".
if (selectedFilter[1] !== null)
+16 -4
View File
@@ -6,8 +6,8 @@ import toast from "react-hot-toast";
import { getFormattedErrorMessage } from "@formbricks/lib/actionClient/helper";
import { templates } from "@formbricks/lib/templates";
import type { TEnvironment } from "@formbricks/types/environment";
import { type TProduct, ZProductConfigIndustry } from "@formbricks/types/product";
import { TSurveyCreateInput, ZSurveyType } from "@formbricks/types/surveys/types";
import { type TProduct, ZProductConfigChannel, ZProductConfigIndustry } from "@formbricks/types/product";
import { TSurveyCreateInput, TSurveyType } from "@formbricks/types/surveys/types";
import { TTemplate, TTemplateFilter, ZTemplateRole } from "@formbricks/types/templates";
import { TUser } from "@formbricks/types/user";
import { createSurveyAction } from "./actions";
@@ -37,9 +37,20 @@ export const TemplateList = ({
const [loading, setLoading] = useState(false);
const [selectedFilter, setSelectedFilter] = useState<TTemplateFilter[]>(prefilledFilters);
const surveyType: TSurveyType = useMemo(() => {
if (product.config.channel) {
if (product.config.channel === "website") {
return "app";
}
return product.config.channel;
}
return "link";
}, [product.config.channel]);
const createSurvey = async (activeTemplate: TTemplate) => {
setLoading(true);
const surveyType = product.config.channel ?? "link";
const augmentedTemplate: TSurveyCreateInput = {
...activeTemplate.preset,
type: surveyType,
@@ -63,8 +74,9 @@ export const TemplateList = ({
if (templateSearch) {
return template.name.toLowerCase().includes(templateSearch.toLowerCase());
}
// Parse and validate the filters
const channelParseResult = ZSurveyType.nullable().safeParse(selectedFilter[0]);
const channelParseResult = ZProductConfigChannel.nullable().safeParse(selectedFilter[0]);
const industryParseResult = ZProductConfigIndustry.nullable().safeParse(selectedFilter[1]);
const roleParseResult = ZTemplateRole.nullable().safeParse(selectedFilter[2]);
@@ -1,7 +1,7 @@
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
import { structuredClone } from "@formbricks/lib/pollyfills/structuredClone";
import { TProduct, TProductConfigIndustry } from "@formbricks/types/product";
import { TSurveyQuestion, TSurveyType } from "@formbricks/types/surveys/types";
import { TProduct, TProductConfigChannel, TProductConfigIndustry } from "@formbricks/types/product";
import { TSurveyQuestion } from "@formbricks/types/surveys/types";
import { TTemplate, TTemplateRole } from "@formbricks/types/templates";
export const replaceQuestionPresetPlaceholders = (
@@ -36,7 +36,7 @@ export const replacePresetPlaceholders = (template: TTemplate, product: any) =>
return { ...template, preset };
};
export const channelMapping: { value: TSurveyType; label: string }[] = [
export const channelMapping: { value: TProductConfigChannel; label: string }[] = [
{ value: "website", label: "Website Survey" },
{ value: "app", label: "App Survey" },
{ value: "link", label: "Link Survey" },
+2 -2
View File
@@ -33,14 +33,14 @@ TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
interface TooltipRendererProps {
shouldRender: boolean;
tooltipContent: ReactNode;
children: ReactNode;
className?: string;
triggerClass?: string;
shouldRender?: boolean;
}
export const TooltipRenderer = (props: TooltipRendererProps) => {
const { children, shouldRender, tooltipContent, className, triggerClass } = props;
const { children, shouldRender = true, tooltipContent, className, triggerClass } = props;
if (shouldRender) {
return (
<TooltipProvider delayDuration={0}>