mirror of
https://github.com/formbricks/formbricks.git
synced 2026-02-02 03:09:46 -06:00
Compare commits
6 Commits
delete-ava
...
v2.3.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8731f2afe5 | ||
|
|
323df36a97 | ||
|
|
bcf71b583c | ||
|
|
9268407429 | ||
|
|
1ff87d27ca | ||
|
|
d6e4b7700f |
@@ -13,7 +13,7 @@
|
||||
"dependencies": {
|
||||
"@formbricks/js": "workspace:*",
|
||||
"@formbricks/ui": "workspace:*",
|
||||
"lucide-react": "^0.395.0",
|
||||
"lucide-react": "^0.397.0",
|
||||
"next": "14.2.4",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1"
|
||||
|
||||
@@ -8,6 +8,106 @@ export const metadata = {
|
||||
|
||||
# Migration Guide
|
||||
|
||||
## v2.3
|
||||
|
||||
Formbricks v2.3 includes new color options for rating questions, improved multi-language functionality for Chinese (Simplified & Traditional), and various bug fixes and performance improvements.
|
||||
|
||||
### Steps to Migrate
|
||||
|
||||
<Note>
|
||||
You only need to run the data migration if you have multi language surveys set up in the Chinese language
|
||||
(`cn`). If you don't have any surveys in Chinese, you can skip the data migration step.
|
||||
</Note>
|
||||
|
||||
This guide is for users who are self-hosting Formbricks using our one-click setup. If you are using a different setup, you might adjust the commands accordingly.
|
||||
|
||||
To run all these steps, please navigate to the `formbricks` folder where your `docker-compose.yml` file is located.
|
||||
|
||||
1. **Backup your Database**: This is a crucial step. Please make sure to backup your database before proceeding with the upgrade. You can use the following command to backup your database:
|
||||
|
||||
<Col>
|
||||
<CodeGroup title="Backup Postgres">
|
||||
|
||||
```bash
|
||||
docker exec formbricks-postgres-1 pg_dump -Fc -U postgres -d formbricks > formbricks_pre_v2.3_$(date +%Y%m%d_%H%M%S).dump
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
</Col>
|
||||
|
||||
<Note>
|
||||
If you run into “No such container”, use `docker ps` to find your container name, e.g.
|
||||
`formbricks_postgres_1`.
|
||||
</Note>
|
||||
|
||||
<Note>
|
||||
If you prefer storing the backup as an `*.sql` file remove the `-Fc` (custom format) option. In case of a
|
||||
restore scenario you will need to use `psql` then with an empty `formbricks` database.
|
||||
</Note>
|
||||
|
||||
2. Pull the latest version of Formbricks:
|
||||
|
||||
<Col>
|
||||
<CodeGroup title="Stop the containers">
|
||||
|
||||
```bash
|
||||
docker compose pull
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
</Col>
|
||||
|
||||
3. Stop the running Formbricks instance & remove the related containers:
|
||||
|
||||
<Col>
|
||||
<CodeGroup title="Stop the containers">
|
||||
|
||||
```bash
|
||||
docker compose down
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
</Col>
|
||||
|
||||
4. Restarting the containers with the latest version of Formbricks:
|
||||
|
||||
<Col>
|
||||
<CodeGroup title="Restart the containers">
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
</Col>
|
||||
|
||||
5. Now let's migrate the data to the latest schema:
|
||||
|
||||
<Note>To find your Docker Network name for your Postgres Database, find it using `docker network ls`</Note>
|
||||
|
||||
<Col>
|
||||
<CodeGroup title="Migrate the data">
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/formbricks/data-migrations:latest && \
|
||||
docker run --rm \
|
||||
--network=formbricks_default \
|
||||
-e DATABASE_URL="postgresql://postgres:postgres@postgres:5432/formbricks?schema=public" \
|
||||
-e UPGRADE_TO_VERSION="v2.3" \
|
||||
ghcr.io/formbricks/data-migrations:latest
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
</Col>
|
||||
|
||||
The above command will migrate your data to the latest schema. This is a crucial step to migrate your existing data to the new structure. Only if the script runs successful, changes are made to the database. The script can safely run multiple times.
|
||||
|
||||
6. That's it! Once the migration is complete, you can **now access your Formbricks instance** at the same URL as before.
|
||||
|
||||
### Additional Updates
|
||||
|
||||
The feature to create short urls in Formbricks is now deprecated. Previously generated short urls will keep working for now but we recommend to use the long urls instead. Redirect support for short urls will be removed in a future release.
|
||||
|
||||
## v2.2
|
||||
|
||||
Formbricks v2.2 introduces XM research presets into your products with a brand new product onboarding. Our objective is to make user research “obviously easy”, industry by industry. And we're starting with Software-as-a-Service and E-Commerce.
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"@formbricks/lib": "workspace:*",
|
||||
"@formbricks/types": "workspace:*",
|
||||
"@formbricks/ui": "workspace:*",
|
||||
"@headlessui/react": "^2.0.4",
|
||||
"@headlessui/react": "^2.1.1",
|
||||
"@headlessui/tailwindcss": "^0.2.1",
|
||||
"@mapbox/rehype-prism": "^0.9.0",
|
||||
"@mdx-js/loader": "^3.0.1",
|
||||
@@ -33,10 +33,10 @@
|
||||
"clsx": "^2.1.1",
|
||||
"fast-glob": "^3.3.2",
|
||||
"flexsearch": "^0.7.43",
|
||||
"framer-motion": "11.2.11",
|
||||
"framer-motion": "11.2.12",
|
||||
"lottie-web": "^5.12.2",
|
||||
"lucide": "^0.395.0",
|
||||
"lucide-react": "^0.395.0",
|
||||
"lucide": "^0.397.0",
|
||||
"lucide-react": "^0.397.0",
|
||||
"mdast-util-to-string": "^4.0.0",
|
||||
"mdx-annotations": "^0.1.4",
|
||||
"next": "14.2.4",
|
||||
@@ -62,7 +62,7 @@
|
||||
"tailwindcss": "^3.4.4",
|
||||
"unist-util-filter": "^5.0.1",
|
||||
"unist-util-visit": "^5.0.0",
|
||||
"zustand": "^4.5.2"
|
||||
"zustand": "^4.5.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@formbricks/config-typescript": "workspace:*",
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { MainNavigation } from "@/app/(app)/environments/[environmentId]/components/MainNavigation";
|
||||
import { TopControlBar } from "@/app/(app)/environments/[environmentId]/components/TopControlBar";
|
||||
import type { Session } from "next-auth";
|
||||
import { getIsMultiOrgEnabled } from "@formbricks/ee/lib/service";
|
||||
import { getEnterpriseLicense } from "@formbricks/ee/lib/service";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
|
||||
import { getEnvironment, getEnvironments } from "@formbricks/lib/environment/service";
|
||||
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
|
||||
import {
|
||||
getMonthlyActiveOrganizationPeopleCount,
|
||||
getMonthlyOrganizationResponseCount,
|
||||
getOrganizationByEnvironmentId,
|
||||
getOrganizationsByUserId,
|
||||
} from "@formbricks/lib/organization/service";
|
||||
@@ -13,6 +15,7 @@ import { getProducts } from "@formbricks/lib/product/service";
|
||||
import { DevEnvironmentBanner } from "@formbricks/ui/DevEnvironmentBanner";
|
||||
import { ErrorComponent } from "@formbricks/ui/ErrorComponent";
|
||||
import { LimitsReachedBanner } from "@formbricks/ui/LimitsReachedBanner";
|
||||
import { PendingDowngradeBanner } from "@formbricks/ui/PendingDowngradeBanner";
|
||||
|
||||
interface EnvironmentLayoutProps {
|
||||
environmentId: string;
|
||||
@@ -41,16 +44,42 @@ export const EnvironmentLayout = async ({ environmentId, session, children }: En
|
||||
}
|
||||
|
||||
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
|
||||
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
|
||||
const { features, lastChecked, isPendingDowngrade, active } = await getEnterpriseLicense();
|
||||
|
||||
const isMultiOrgEnabled = features?.isMultiOrgEnabled ?? false;
|
||||
|
||||
const currentProductChannel =
|
||||
products.find((product) => product.id === environment.productId)?.config.channel ?? null;
|
||||
|
||||
let peopleCount = 0;
|
||||
let responseCount = 0;
|
||||
|
||||
if (IS_FORMBRICKS_CLOUD) {
|
||||
[peopleCount, responseCount] = await Promise.all([
|
||||
getMonthlyActiveOrganizationPeopleCount(organization.id),
|
||||
getMonthlyOrganizationResponseCount(organization.id),
|
||||
]);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen min-h-screen flex-col overflow-hidden">
|
||||
<DevEnvironmentBanner environment={environment} />
|
||||
|
||||
{IS_FORMBRICKS_CLOUD && <LimitsReachedBanner organization={organization} />}
|
||||
{IS_FORMBRICKS_CLOUD && (
|
||||
<LimitsReachedBanner
|
||||
organization={organization}
|
||||
environmentId={environment.id}
|
||||
peopleCount={peopleCount}
|
||||
responseCount={responseCount}
|
||||
/>
|
||||
)}
|
||||
|
||||
<PendingDowngradeBanner
|
||||
lastChecked={lastChecked}
|
||||
isPendingDowngrade={isPendingDowngrade ?? false}
|
||||
active={active}
|
||||
environmentId={environment.id}
|
||||
/>
|
||||
|
||||
<div className="flex h-full">
|
||||
<MainNavigation
|
||||
|
||||
@@ -291,7 +291,7 @@ export const AddIntegrationModal = ({
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="Surveys">Questions</Label>
|
||||
<div className="mt-1 rounded-lg border border-slate-200">
|
||||
<div className="mt-1 max-h-[15vh] overflow-y-auto rounded-lg border border-slate-200">
|
||||
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
|
||||
{replaceHeadlineRecall(selectedSurvey, "default", attributeClasses)?.questions.map(
|
||||
(question) => (
|
||||
|
||||
@@ -229,7 +229,7 @@ export const AddIntegrationModal = ({
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="Surveys">Questions</Label>
|
||||
<div className="mt-1 rounded-lg border border-slate-200">
|
||||
<div className="mt-1 max-h-[15vh] overflow-y-auto rounded-lg border border-slate-200">
|
||||
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
|
||||
{replaceHeadlineRecall(selectedSurvey, "default", attributeClasses)?.questions.map(
|
||||
(question) => (
|
||||
|
||||
@@ -231,7 +231,7 @@ export const AddChannelMappingModal = ({
|
||||
<div>
|
||||
<div>
|
||||
<Label htmlFor="Surveys">Questions</Label>
|
||||
<div className="mt-1 rounded-lg border border-slate-200">
|
||||
<div className="mt-1 max-h-[15vh] overflow-y-auto rounded-lg border border-slate-200">
|
||||
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
|
||||
{replaceHeadlineRecall(selectedSurvey, "default", attributeClasses)?.questions?.map(
|
||||
(question) => (
|
||||
|
||||
@@ -2,7 +2,7 @@ import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmen
|
||||
import { CheckIcon } from "lucide-react";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getIsEnterpriseEdition } from "@formbricks/ee/lib/service";
|
||||
import { getEnterpriseLicense } from "@formbricks/ee/lib/service";
|
||||
import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
|
||||
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
|
||||
@@ -37,7 +37,7 @@ const Page = async ({ params }) => {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const isEnterpriseEdition = await getIsEnterpriseEdition();
|
||||
const { active: isEnterpriseEdition } = await getEnterpriseLicense();
|
||||
|
||||
const paidFeatures = [
|
||||
{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { CacheHandler } from "@neshca/cache-handler";
|
||||
import createLruHandler from "@neshca/cache-handler/local-lru";
|
||||
import createRedisHandler from "@neshca/cache-handler/redis-stack";
|
||||
import createRedisHandler from "@neshca/cache-handler/redis-strings";
|
||||
import { createClient } from "redis";
|
||||
|
||||
// Function to create a timeout promise
|
||||
@@ -27,6 +27,7 @@ CacheHandler.onCreation(async () => {
|
||||
if (client) {
|
||||
try {
|
||||
// Wait for the client to connect with a timeout of 5000ms.
|
||||
const connectPromise = client.connect();
|
||||
const timeoutPromise = createTimeoutPromise(5000, "Redis connection timed out"); // 5000ms timeout
|
||||
await Promise.race([connectPromise, timeoutPromise]);
|
||||
} catch (error) {
|
||||
@@ -53,7 +54,7 @@ CacheHandler.onCreation(async () => {
|
||||
// Create the `redis-stack` Handler if the client is available and connected.
|
||||
handler = await createRedisHandler({
|
||||
client,
|
||||
keyPrefix: "formbricks:",
|
||||
keyPrefix: "fb:",
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
} else {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@formbricks/web",
|
||||
"version": "2.2.2",
|
||||
"version": "2.3.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"clean": "rimraf .turbo node_modules .next",
|
||||
@@ -29,27 +29,27 @@
|
||||
"@hookform/resolvers": "^3.6.0",
|
||||
"@json2csv/node": "^7.0.6",
|
||||
"@paralleldrive/cuid2": "^2.2.2",
|
||||
"@radix-ui/react-collapsible": "^1.0.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.0",
|
||||
"@react-email/components": "^0.0.19",
|
||||
"@sentry/nextjs": "^8.10.0",
|
||||
"@sentry/nextjs": "^8.12.0",
|
||||
"@vercel/og": "^0.6.2",
|
||||
"@vercel/speed-insights": "^1.0.12",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"dotenv": "^16.4.5",
|
||||
"encoding": "^0.1.13",
|
||||
"framer-motion": "11.2.11",
|
||||
"googleapis": "^140.0.0",
|
||||
"framer-motion": "11.2.12",
|
||||
"googleapis": "^140.0.1",
|
||||
"jiti": "^1.21.6",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"lodash": "^4.17.21",
|
||||
"lru-cache": "^10.2.2",
|
||||
"lucide-react": "^0.395.0",
|
||||
"lucide-react": "^0.397.0",
|
||||
"mime": "^4.0.3",
|
||||
"next": "15.0.0-rc.0",
|
||||
"optional": "^0.1.4",
|
||||
"otplib": "^12.0.1",
|
||||
"papaparse": "^5.4.1",
|
||||
"posthog-js": "^1.139.3",
|
||||
"posthog-js": "^1.141.4",
|
||||
"prismjs": "^1.29.0",
|
||||
"qrcode": "^1.5.3",
|
||||
"react": "19.0.0-rc-935180c7e0-20240524",
|
||||
@@ -59,7 +59,7 @@
|
||||
"redis": "^4.6.14",
|
||||
"sharp": "^0.33.4",
|
||||
"ua-parser-js": "^1.0.38",
|
||||
"webpack": "^5.92.0",
|
||||
"webpack": "^5.92.1",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
10
package.json
10
package.json
@@ -32,14 +32,14 @@
|
||||
"storybook": "turbo run storybook"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.44.1",
|
||||
"@playwright/test": "^1.45.0",
|
||||
"@formbricks/eslint-config": "workspace:*",
|
||||
"eslint": "^8.57.0",
|
||||
"husky": "^9.0.11",
|
||||
"lint-staged": "^15.2.7",
|
||||
"rimraf": "^5.0.7",
|
||||
"tsx": "^4.15.6",
|
||||
"turbo": "^2.0.4"
|
||||
"tsx": "^4.15.7",
|
||||
"turbo": "^2.0.5"
|
||||
},
|
||||
"lint-staged": {
|
||||
"(apps|packages)/**/*.{js,ts,jsx,tsx}": [
|
||||
@@ -63,7 +63,7 @@
|
||||
"showDetails": true
|
||||
},
|
||||
"dependencies": {
|
||||
"@changesets/cli": "^2.27.5",
|
||||
"playwright": "^1.44.1"
|
||||
"@changesets/cli": "^2.27.6",
|
||||
"playwright": "^1.45.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,8 @@
|
||||
"data-migration:adds_app_and_website_status_indicator": "ts-node ./data-migrations/20240610055828_adds_app_and_website_status_indicators/data-migration.ts",
|
||||
"data-migration:product-config": "ts-node ./data-migrations/20240612115151_adds_product_config/data-migration.ts",
|
||||
"data-migration:v2.2": "pnpm data-migration:adds_app_and_website_status_indicator && pnpm data-migration:product-config && pnpm data-migration:pricing-v2",
|
||||
"data-migration:zh-to-zh-Hans": "ts-node ./data-migrations/20240625101352_update_zh_to_zh-Hans/data-migration.ts"
|
||||
"data-migration:zh-to-zh-Hans": "ts-node ./data-migrations/20240625101352_update_zh_to_zh-Hans/data-migration.ts",
|
||||
"data-migration:v2.3": "pnpm data-migration:zh-to-zh-Hans"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.15.1",
|
||||
|
||||
@@ -19,7 +19,7 @@ const PREVIOUS_RESULTS_CACHE_TAG_KEY = `getPreviousResult-${hashedKey}` as const
|
||||
|
||||
// This function is used to get the previous result of the license check from the cache
|
||||
// This might seem confusing at first since we only return the default value from this function,
|
||||
// but since we are using a cache and the cache key is the same, the cache will return the previous result - so this functions as a cache getter
|
||||
// but since we are using a cache and the cache key is the same, the cache will return the previous result - so this function acts as a cache getter
|
||||
const getPreviousResult = (): Promise<{
|
||||
active: boolean | null;
|
||||
lastChecked: Date;
|
||||
@@ -89,47 +89,87 @@ const fetchLicenseForE2ETesting = async (): Promise<{
|
||||
}
|
||||
};
|
||||
|
||||
export const getIsEnterpriseEdition = async (): Promise<boolean> => {
|
||||
export const getEnterpriseLicense = async (): Promise<{
|
||||
active: boolean;
|
||||
features: TEnterpriseLicenseFeatures | null;
|
||||
lastChecked: Date;
|
||||
isPendingDowngrade?: boolean;
|
||||
}> => {
|
||||
if (!ENTERPRISE_LICENSE_KEY || ENTERPRISE_LICENSE_KEY.length === 0) {
|
||||
return false;
|
||||
return {
|
||||
active: false,
|
||||
features: null,
|
||||
lastChecked: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
if (E2E_TESTING) {
|
||||
const previousResult = await fetchLicenseForE2ETesting();
|
||||
return previousResult && previousResult.active !== null ? previousResult.active : false;
|
||||
|
||||
return {
|
||||
active: previousResult?.active ?? false,
|
||||
features: previousResult ? previousResult.features : null,
|
||||
lastChecked: previousResult ? previousResult.lastChecked : new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
// if the server responds with a boolean, we return it
|
||||
// if the server errors, we return null
|
||||
// null signifies an error
|
||||
const license = await fetchLicense();
|
||||
|
||||
const isValid = license ? license.status === "active" : null;
|
||||
const threeDaysInMillis = 3 * 24 * 60 * 60 * 1000;
|
||||
const currentTime = new Date();
|
||||
|
||||
const previousResult = await getPreviousResult();
|
||||
|
||||
// Case: First time checking license and the server errors out
|
||||
if (previousResult.active === null) {
|
||||
if (isValid === null) {
|
||||
await setPreviousResult({
|
||||
const newResult = {
|
||||
active: false,
|
||||
features: { isMultiOrgEnabled: false },
|
||||
lastChecked: new Date(),
|
||||
});
|
||||
return false;
|
||||
};
|
||||
|
||||
await setPreviousResult(newResult);
|
||||
return newResult;
|
||||
}
|
||||
}
|
||||
|
||||
if (isValid !== null && license) {
|
||||
await setPreviousResult({ active: isValid, features: license.features, lastChecked: new Date() });
|
||||
return isValid;
|
||||
const newResult = {
|
||||
active: isValid,
|
||||
features: license.features,
|
||||
lastChecked: new Date(),
|
||||
};
|
||||
|
||||
await setPreviousResult(newResult);
|
||||
return newResult;
|
||||
} else {
|
||||
// if result is undefined -> error
|
||||
// if the last check was less than 72 hours, return the previous value:
|
||||
if (new Date().getTime() - previousResult.lastChecked.getTime() <= 3 * 24 * 60 * 60 * 1000) {
|
||||
return previousResult.active !== null ? previousResult.active : false;
|
||||
|
||||
const elapsedTime = currentTime.getTime() - previousResult.lastChecked.getTime();
|
||||
if (elapsedTime < threeDaysInMillis) {
|
||||
return {
|
||||
active: previousResult.active !== null ? previousResult.active : false,
|
||||
features: previousResult.features,
|
||||
lastChecked: previousResult.lastChecked,
|
||||
isPendingDowngrade: true,
|
||||
};
|
||||
}
|
||||
|
||||
// if the last check was more than 72 hours, return false and log the error
|
||||
// Log error only after 72 hours
|
||||
console.error("Error while checking license: The license check failed");
|
||||
return false;
|
||||
|
||||
return {
|
||||
active: false,
|
||||
features: null,
|
||||
lastChecked: previousResult.lastChecked,
|
||||
isPendingDowngrade: true,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -139,14 +179,13 @@ export const getLicenseFeatures = async (): Promise<TEnterpriseLicenseFeatures |
|
||||
return previousResult.features;
|
||||
} else {
|
||||
const license = await fetchLicense();
|
||||
if (!license) return null;
|
||||
const features = await license.features;
|
||||
return features;
|
||||
if (!license || !license.features) return null;
|
||||
return license.features;
|
||||
}
|
||||
};
|
||||
|
||||
export const fetchLicense = async () => {
|
||||
const licenseResult: TEnterpriseLicenseDetails | null = await cache(
|
||||
export const fetchLicense = async (): Promise<TEnterpriseLicenseDetails | null> =>
|
||||
await cache(
|
||||
async () => {
|
||||
if (!env.ENTERPRISE_LICENSE_KEY) return null;
|
||||
try {
|
||||
@@ -192,8 +231,6 @@ export const fetchLicense = async () => {
|
||||
[`fetchLicense-${hashedKey}`],
|
||||
{ revalidate: 60 * 60 * 24 }
|
||||
)();
|
||||
return licenseResult;
|
||||
};
|
||||
|
||||
export const getRemoveInAppBrandingPermission = (organization: TOrganization): boolean => {
|
||||
if (IS_FORMBRICKS_CLOUD) return organization.billing.plan !== PRODUCT_FEATURE_KEYS.FREE;
|
||||
@@ -213,7 +250,7 @@ export const getRoleManagementPermission = async (organization: TOrganization):
|
||||
organization.billing.plan === PRODUCT_FEATURE_KEYS.SCALE ||
|
||||
organization.billing.plan === PRODUCT_FEATURE_KEYS.ENTERPRISE
|
||||
);
|
||||
else if (!IS_FORMBRICKS_CLOUD) return await getIsEnterpriseEdition();
|
||||
else if (!IS_FORMBRICKS_CLOUD) return (await getEnterpriseLicense()).active;
|
||||
return false;
|
||||
};
|
||||
|
||||
@@ -223,13 +260,13 @@ export const getAdvancedTargetingPermission = async (organization: TOrganization
|
||||
organization.billing.plan === PRODUCT_FEATURE_KEYS.SCALE ||
|
||||
organization.billing.plan === PRODUCT_FEATURE_KEYS.ENTERPRISE
|
||||
);
|
||||
else if (!IS_FORMBRICKS_CLOUD) return await getIsEnterpriseEdition();
|
||||
else if (!IS_FORMBRICKS_CLOUD) return (await getEnterpriseLicense()).active;
|
||||
else return false;
|
||||
};
|
||||
|
||||
export const getBiggerUploadFileSizePermission = async (organization: TOrganization): Promise<boolean> => {
|
||||
if (IS_FORMBRICKS_CLOUD) return organization.billing.plan !== PRODUCT_FEATURE_KEYS.FREE;
|
||||
else if (!IS_FORMBRICKS_CLOUD) return await getIsEnterpriseEdition();
|
||||
else if (!IS_FORMBRICKS_CLOUD) return (await getEnterpriseLicense()).active;
|
||||
return false;
|
||||
};
|
||||
|
||||
@@ -243,7 +280,7 @@ export const getMultiLanguagePermission = async (organization: TOrganization): P
|
||||
organization.billing.plan === PRODUCT_FEATURE_KEYS.SCALE ||
|
||||
organization.billing.plan === PRODUCT_FEATURE_KEYS.ENTERPRISE
|
||||
);
|
||||
else if (!IS_FORMBRICKS_CLOUD) return await getIsEnterpriseEdition();
|
||||
else if (!IS_FORMBRICKS_CLOUD) return (await getEnterpriseLicense()).active;
|
||||
return false;
|
||||
};
|
||||
|
||||
|
||||
@@ -324,7 +324,6 @@ export const deleteFile = async (environmentId: string, accessType: TAccessType,
|
||||
await deleteS3File(`${environmentId}/${accessType}/${fileName}`);
|
||||
return { success: true, message: "File deleted" };
|
||||
} catch (err: any) {
|
||||
console.log(err);
|
||||
if (err.name === "NoSuchKey") {
|
||||
return { success: false, message: "File not found", code: 404 };
|
||||
} else {
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import { JSX } from "preact";
|
||||
import { useCallback } from "preact/hooks";
|
||||
|
||||
interface SubmitButtonProps {
|
||||
interface SubmitButtonProps extends JSX.HTMLAttributes<HTMLButtonElement> {
|
||||
buttonLabel: string | undefined;
|
||||
isLastQuestion: boolean;
|
||||
onClick?: () => void;
|
||||
focus?: boolean;
|
||||
tabIndex?: number;
|
||||
type?: "submit" | "button";
|
||||
}
|
||||
|
||||
export const SubmitButton = ({
|
||||
buttonLabel,
|
||||
isLastQuestion,
|
||||
onClick = () => {},
|
||||
tabIndex = 1,
|
||||
focus = false,
|
||||
type = "submit",
|
||||
onClick,
|
||||
disabled,
|
||||
type,
|
||||
...props
|
||||
}: SubmitButtonProps) => {
|
||||
const buttonRef = useCallback(
|
||||
(currentButton: HTMLButtonElement | null) => {
|
||||
@@ -30,13 +30,15 @@ export const SubmitButton = ({
|
||||
|
||||
return (
|
||||
<button
|
||||
{...props}
|
||||
dir="auto"
|
||||
ref={buttonRef}
|
||||
type={type}
|
||||
tabIndex={tabIndex}
|
||||
autoFocus={focus}
|
||||
className="bg-brand border-submit-button-border text-on-brand focus:ring-focus rounded-custom flex items-center border px-3 py-3 text-base font-medium leading-4 shadow-sm hover:opacity-90 focus:outline-none focus:ring-2 focus:ring-offset-2"
|
||||
onClick={onClick}>
|
||||
onClick={onClick}
|
||||
disabled={disabled}>
|
||||
{buttonLabel || (isLastQuestion ? "Finish" : "Next")}
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -29,5 +29,9 @@ export const ProgressBar = ({ survey, questionId }: ProgressBarProps) => {
|
||||
return survey.questions.map((_, index) => calculateProgress(index, survey.questions.length));
|
||||
}, [calculateProgress, survey]);
|
||||
|
||||
return <Progress progress={questionId === "end" ? 1 : progressArray[currentQuestionIdx]} />;
|
||||
return (
|
||||
<Progress
|
||||
progress={questionId === "end" ? 1 : questionId === "start" ? 0 : progressArray[currentQuestionIdx]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -253,6 +253,7 @@ export const Survey = ({
|
||||
if (questionIdx === -1) {
|
||||
return (
|
||||
<WelcomeCard
|
||||
key="start"
|
||||
headline={survey.welcomeCard.headline}
|
||||
html={survey.welcomeCard.html}
|
||||
fileUrl={survey.welcomeCard.fileUrl}
|
||||
@@ -263,11 +264,13 @@ export const Survey = ({
|
||||
responseCount={responseCount}
|
||||
autoFocusEnabled={autoFocusEnabled}
|
||||
replaceRecallInfo={replaceRecallInfo}
|
||||
isCurrent={offset === 0}
|
||||
/>
|
||||
);
|
||||
} else if (questionIdx === survey.questions.length) {
|
||||
return (
|
||||
<ThankYouCard
|
||||
key="end"
|
||||
headline={replaceRecallInfo(
|
||||
getLocalizedValue(survey.thankYouCard.headline, selectedLanguage),
|
||||
responseData
|
||||
@@ -279,11 +282,13 @@ export const Survey = ({
|
||||
isResponseSendingFinished={isResponseSendingFinished}
|
||||
buttonLabel={getLocalizedValue(survey.thankYouCard.buttonLabel, selectedLanguage)}
|
||||
buttonLink={survey.thankYouCard.buttonLink}
|
||||
survey={survey}
|
||||
imageUrl={survey.thankYouCard.imageUrl}
|
||||
videoUrl={survey.thankYouCard.videoUrl}
|
||||
redirectUrl={survey.redirectUrl}
|
||||
isRedirectDisabled={isRedirectDisabled}
|
||||
autoFocusEnabled={autoFocusEnabled}
|
||||
isCurrent={offset === 0}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
@@ -291,6 +296,7 @@ export const Survey = ({
|
||||
return (
|
||||
question && (
|
||||
<QuestionConditional
|
||||
key={question.id}
|
||||
surveyId={survey.id}
|
||||
question={parseRecallInformation(question, selectedLanguage, responseData)}
|
||||
value={responseData[question.id]}
|
||||
|
||||
@@ -5,6 +5,8 @@ import { QuestionMedia } from "@/components/general/QuestionMedia";
|
||||
import { RedirectCountDown } from "@/components/general/RedirectCountdown";
|
||||
import { Subheader } from "@/components/general/Subheader";
|
||||
import { ScrollableContainer } from "@/components/wrappers/ScrollableContainer";
|
||||
import { useEffect } from "preact/hooks";
|
||||
import { TSurvey } from "@formbricks/types/surveys";
|
||||
|
||||
interface ThankYouCardProps {
|
||||
headline?: string;
|
||||
@@ -17,6 +19,8 @@ interface ThankYouCardProps {
|
||||
videoUrl?: string;
|
||||
isResponseSendingFinished: boolean;
|
||||
autoFocusEnabled: boolean;
|
||||
isCurrent: boolean;
|
||||
survey: TSurvey;
|
||||
}
|
||||
|
||||
export const ThankYouCard = ({
|
||||
@@ -30,6 +34,8 @@ export const ThankYouCard = ({
|
||||
videoUrl,
|
||||
isResponseSendingFinished,
|
||||
autoFocusEnabled,
|
||||
isCurrent,
|
||||
survey,
|
||||
}: ThankYouCardProps) => {
|
||||
const media = imageUrl || videoUrl ? <QuestionMedia imgUrl={imageUrl} videoUrl={videoUrl} /> : null;
|
||||
const checkmark = (
|
||||
@@ -51,6 +57,30 @@ export const ThankYouCard = ({
|
||||
</div>
|
||||
);
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (buttonLink) window.location.replace(buttonLink);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleEnter = (e: KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
handleSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
if (isCurrent && survey.type === "link") {
|
||||
document.addEventListener("keydown", handleEnter);
|
||||
} else {
|
||||
document.removeEventListener("keydown", handleEnter);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleEnter);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isCurrent]);
|
||||
|
||||
return (
|
||||
<ScrollableContainer>
|
||||
<div className="text-center">
|
||||
@@ -66,10 +96,7 @@ export const ThankYouCard = ({
|
||||
buttonLabel={buttonLabel}
|
||||
isLastQuestion={false}
|
||||
focus={autoFocusEnabled}
|
||||
onClick={() => {
|
||||
if (!buttonLink) return;
|
||||
window.location.replace(buttonLink);
|
||||
}}
|
||||
onClick={handleSubmit}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { SubmitButton } from "@/components/buttons/SubmitButton";
|
||||
import { ScrollableContainer } from "@/components/wrappers/ScrollableContainer";
|
||||
import { calculateElementIdx } from "@/lib/utils";
|
||||
import { useEffect } from "preact/hooks";
|
||||
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
|
||||
import { TResponseData, TResponseTtc } from "@formbricks/types/responses";
|
||||
import { TI18nString, TSurvey } from "@formbricks/types/surveys";
|
||||
@@ -18,6 +19,7 @@ interface WelcomeCardProps {
|
||||
responseCount?: number;
|
||||
autoFocusEnabled: boolean;
|
||||
replaceRecallInfo: (text: string, responseData: TResponseData) => string;
|
||||
isCurrent: boolean;
|
||||
}
|
||||
|
||||
const TimerIcon = () => {
|
||||
@@ -68,6 +70,7 @@ export const WelcomeCard = ({
|
||||
responseCount,
|
||||
autoFocusEnabled,
|
||||
replaceRecallInfo,
|
||||
isCurrent,
|
||||
}: WelcomeCardProps) => {
|
||||
const calculateTimeToComplete = () => {
|
||||
let idx = calculateElementIdx(survey, 0);
|
||||
@@ -100,6 +103,30 @@ export const WelcomeCard = ({
|
||||
const timeToFinish = survey.welcomeCard.timeToFinish;
|
||||
const showResponseCount = survey.welcomeCard.showResponseCount;
|
||||
|
||||
const handleSubmit = () => {
|
||||
onSubmit({ ["welcomeCard"]: "clicked" }, {});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleEnter = (e: KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
handleSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
if (isCurrent && survey.type === "link") {
|
||||
document.addEventListener("keydown", handleEnter);
|
||||
} else {
|
||||
document.removeEventListener("keydown", handleEnter);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleEnter);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isCurrent]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ScrollableContainer>
|
||||
@@ -124,10 +151,9 @@ export const WelcomeCard = ({
|
||||
buttonLabel={getLocalizedValue(buttonLabel, languageCode)}
|
||||
isLastQuestion={false}
|
||||
focus={autoFocusEnabled}
|
||||
onClick={() => {
|
||||
onSubmit({ ["welcomeCard"]: "clicked" }, {});
|
||||
}}
|
||||
onClick={handleSubmit}
|
||||
type="button"
|
||||
onKeyDown={(e) => e.key === "Enter" && e.preventDefault()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ export const DropdownSelector = ({
|
||||
{!disabled && (
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent
|
||||
className="z-50 max-h-64 min-w-[220px] max-w-[90%] overflow-auto rounded-md bg-white text-sm text-slate-800 shadow-md"
|
||||
className="z-50 max-h-64 min-w-[220px] max-w-96 overflow-auto rounded-md bg-white text-sm text-slate-800 shadow-md"
|
||||
align="start">
|
||||
{items
|
||||
.sort((a, b) => a.name?.localeCompare(b.name))
|
||||
|
||||
@@ -1,53 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import { TriangleAlertIcon, XIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
getMonthlyActiveOrganizationPeopleCount,
|
||||
getMonthlyOrganizationResponseCount,
|
||||
} from "@formbricks/lib/organization/service";
|
||||
import { useState } from "react";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
|
||||
interface LimitsReachedBannerProps {
|
||||
organization: TOrganization;
|
||||
environmentId: string;
|
||||
peopleCount: number;
|
||||
responseCount: number;
|
||||
}
|
||||
|
||||
export const LimitsReachedBanner = async ({ organization }: LimitsReachedBannerProps) => {
|
||||
const [peopleCount, responseCount] = await Promise.all([
|
||||
getMonthlyActiveOrganizationPeopleCount(organization.id),
|
||||
getMonthlyOrganizationResponseCount(organization.id),
|
||||
]);
|
||||
|
||||
export const LimitsReachedBanner = ({
|
||||
organization,
|
||||
peopleCount,
|
||||
responseCount,
|
||||
environmentId,
|
||||
}: LimitsReachedBannerProps) => {
|
||||
const orgBillingPeopleLimit = organization.billing?.limits?.monthly?.miu;
|
||||
const orgBillingResponseLimit = organization.billing?.limits?.monthly?.responses;
|
||||
|
||||
const isPeopleLimitReached = orgBillingPeopleLimit !== null && peopleCount >= orgBillingPeopleLimit;
|
||||
const isResponseLimitReached = orgBillingResponseLimit !== null && responseCount >= orgBillingResponseLimit;
|
||||
|
||||
if (isPeopleLimitReached && isResponseLimitReached) {
|
||||
const [show, setShow] = useState(true);
|
||||
|
||||
if (show && (isPeopleLimitReached || isResponseLimitReached)) {
|
||||
return (
|
||||
<>
|
||||
<div className="z-40 flex h-5 items-center justify-center bg-orange-800 text-center text-xs text-white">
|
||||
You have reached your monthly MIU limit of {orgBillingPeopleLimit} and response limit of{" "}
|
||||
{orgBillingResponseLimit}. <Link href="https://formbricks.com/pricing#faq">Learn more</Link>
|
||||
<div
|
||||
aria-live="assertive"
|
||||
className="pointer-events-none fixed inset-0 z-[100] flex min-w-80 items-end px-4 py-6 sm:items-start sm:p-6">
|
||||
<div className="flex w-full flex-col items-center space-y-4 sm:items-end">
|
||||
<div className="pointer-events-auto w-full max-w-sm overflow-hidden rounded-lg bg-white shadow-lg ring-1 ring-black ring-opacity-5 transition">
|
||||
<div className="p-4">
|
||||
<div className="relative flex flex-col">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<TriangleAlertIcon className="text-error h-6 w-6" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="ml-3 w-0 flex-1">
|
||||
<p className="text-base font-medium text-gray-900">Limits Reached</p>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
{isPeopleLimitReached && isResponseLimitReached ? (
|
||||
<>
|
||||
You have reached your monthly MIU limit of <span>{orgBillingPeopleLimit}</span> and
|
||||
response limit of {orgBillingResponseLimit}.{" "}
|
||||
</>
|
||||
) : null}
|
||||
{isPeopleLimitReached && !isResponseLimitReached ? (
|
||||
<>You have reached your monthly MIU limit of {orgBillingPeopleLimit}. </>
|
||||
) : null}
|
||||
{!isPeopleLimitReached && isResponseLimitReached ? (
|
||||
<>You have reached your monthly response limit of {orgBillingResponseLimit}. </>
|
||||
) : null}
|
||||
</p>
|
||||
<Link href={`/environments/${environmentId}/settings/billing`}>
|
||||
<span className="text-sm text-slate-900">Learn more</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute right-0 top-0 ml-4 flex flex-shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
|
||||
onClick={() => setShow(false)}>
|
||||
<span className="sr-only">Close</span>
|
||||
<XIcon className="h-5 w-5" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="z-40 flex h-5 items-center justify-center bg-orange-800 text-center text-xs text-white">
|
||||
{isPeopleLimitReached && (
|
||||
<div>
|
||||
You have reached your monthly MIU limit of {orgBillingPeopleLimit}.{" "}
|
||||
<Link href="https://formbricks.com/pricing#faq">Learn more</Link>
|
||||
</div>
|
||||
)}
|
||||
{isResponseLimitReached && (
|
||||
<div>
|
||||
You have reached your monthly response limit of {orgBillingResponseLimit}.{" "}
|
||||
<Link href="https://formbricks.com/pricing#faq">Learn more</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
return null;
|
||||
};
|
||||
|
||||
77
packages/ui/PendingDowngradeBanner/index.tsx
Normal file
77
packages/ui/PendingDowngradeBanner/index.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { TriangleAlertIcon, XIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
|
||||
interface PendingDowngradeBannerProps {
|
||||
lastChecked: Date;
|
||||
active: boolean;
|
||||
isPendingDowngrade: boolean;
|
||||
environmentId: string;
|
||||
}
|
||||
|
||||
export const PendingDowngradeBanner = ({
|
||||
lastChecked,
|
||||
active,
|
||||
isPendingDowngrade,
|
||||
environmentId,
|
||||
}: PendingDowngradeBannerProps) => {
|
||||
const threeDaysInMillis = 3 * 24 * 60 * 60 * 1000;
|
||||
|
||||
const isLastCheckedWithin72Hours = lastChecked
|
||||
? new Date().getTime() - lastChecked.getTime() < threeDaysInMillis
|
||||
: false;
|
||||
|
||||
const scheduledDowngradeDate = new Date(lastChecked.getTime() + threeDaysInMillis);
|
||||
const formattedDate = `${scheduledDowngradeDate.getMonth() + 1}/${scheduledDowngradeDate.getDate()}/${scheduledDowngradeDate.getFullYear()}`;
|
||||
|
||||
const [show, setShow] = useState(true);
|
||||
|
||||
if (show && active && isPendingDowngrade) {
|
||||
return (
|
||||
<div
|
||||
aria-live="assertive"
|
||||
className="pointer-events-none fixed inset-0 z-[100] flex min-w-80 items-end px-4 py-6 sm:items-start sm:p-6">
|
||||
<div className="flex w-full flex-col items-center space-y-4 sm:items-end">
|
||||
<div className="pointer-events-auto w-full max-w-sm overflow-hidden rounded-lg bg-white shadow-lg ring-1 ring-black ring-opacity-5 transition">
|
||||
<div className="p-4">
|
||||
<div className="relative flex flex-col">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<TriangleAlertIcon className="text-error h-6 w-6" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="ml-3 w-0 flex-1">
|
||||
<p className="text-base font-medium text-gray-900">Pending Downgrade</p>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
We were unable to verify your license because the license server is unreachable.{" "}
|
||||
{isLastCheckedWithin72Hours
|
||||
? `You will be downgraded to the Community Edition on ${formattedDate}.`
|
||||
: "You are downgraded to the Community Edition."}
|
||||
</p>
|
||||
|
||||
<Link href={`/environments/${environmentId}/settings/enterprise`}>
|
||||
<span className="text-sm text-slate-900">Learn more</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute right-0 top-0 ml-4 flex flex-shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
|
||||
onClick={() => setShow(false)}>
|
||||
<span className="sr-only">Close</span>
|
||||
<XIcon className="h-5 w-5" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
26106
pnpm-lock.yaml
generated
26106
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user