mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-30 02:10:12 -06:00
solve merge conflicts
This commit is contained in:
30
.github/workflows/chromatic.yml
vendored
Normal file
30
.github/workflows/chromatic.yml
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
name: "Chromatic"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
chromatic:
|
||||
name: Run Chromatic
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
- name: Install dependencies
|
||||
run: pnpm install --config.platform=linux --config.architecture=x64
|
||||
- name: Run Chromatic
|
||||
uses: chromaui/action@latest
|
||||
with:
|
||||
# ⚠️ Make sure to configure a `CHROMATIC_PROJECT_TOKEN` repository secret
|
||||
projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
|
||||
workingDir: apps/storybook
|
||||
@@ -1,10 +1,5 @@
|
||||
name: Docker for Data Migrations
|
||||
|
||||
# This workflow uses actions that are not certified by GitHub.
|
||||
# They are provided by a third-party and are governed by
|
||||
# separate terms of service, privacy policy, and support
|
||||
# documentation.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
@@ -12,7 +7,6 @@ on:
|
||||
- "v*"
|
||||
|
||||
env:
|
||||
# Use docker.io for Docker Hub if empty
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: formbricks/data-migrations
|
||||
DATABASE_URL: "postgresql://postgres:postgres@postgres:5432/formbricks?schema=public"
|
||||
@@ -23,8 +17,6 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
# This is used to complete the identity challenge
|
||||
# with sigstore/fulcio when running outside of PRs.
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
@@ -50,6 +42,7 @@ jobs:
|
||||
tags: |
|
||||
type=ref,event=tag
|
||||
type=raw,value=${{ github.ref_name }}
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v3
|
||||
@@ -66,3 +59,4 @@ jobs:
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
run: |
|
||||
cosign sign --yes ghcr.io/${{ env.IMAGE_NAME }}:${{ github.ref_name }}
|
||||
cosign sign --yes ghcr.io/${{ env.IMAGE_NAME }}:latest
|
||||
|
||||
@@ -1,4 +1 @@
|
||||
#!/usr/bin/env sh
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
pnpm lint-staged
|
||||
@@ -13,8 +13,8 @@
|
||||
"dependencies": {
|
||||
"@formbricks/js": "workspace:*",
|
||||
"@formbricks/ui": "workspace:*",
|
||||
"lucide-react": "^0.397.0",
|
||||
"next": "14.2.4",
|
||||
"lucide-react": "^0.418.0",
|
||||
"next": "14.2.5",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1"
|
||||
},
|
||||
|
||||
@@ -8,6 +8,108 @@ export const metadata = {
|
||||
|
||||
# Migration Guide
|
||||
|
||||
## v2.4
|
||||
|
||||
Formbricks v2.4 allows you to create multiple endings for your surveys and decide which ending the user should see based on logic jumps. This release also includes many bug fixes and performance improvements.
|
||||
|
||||
<Note>
|
||||
This release will drop support for advanced targeting (enterprise targeting for app surveys) with actions
|
||||
(e.g. only target users that triggered action x 3 times in the last month). This means that actions can
|
||||
still be used as triggers, but will no longer be stored on the server in order to improve the overall
|
||||
performance of the Formbricks system.
|
||||
</Note>
|
||||
|
||||
### Steps to Migrate
|
||||
|
||||
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.4_$(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.4" \
|
||||
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 `CRON_SECRET` environment variable is now required to improve the security of the internal cron APIs. Please make sure that the variable is set in your environment / docker-compose.yml. You can use `openssl rand -hex 32` to generate a secure secret.
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -12,34 +12,34 @@
|
||||
},
|
||||
"browserslist": "defaults, not ie <= 11",
|
||||
"dependencies": {
|
||||
"@algolia/autocomplete-core": "^1.17.2",
|
||||
"@algolia/autocomplete-core": "^1.17.4",
|
||||
"@calcom/embed-react": "^1.5.0",
|
||||
"@docsearch/css": "3",
|
||||
"@docsearch/react": "^3.6.0",
|
||||
"@docsearch/react": "^3.6.1",
|
||||
"@formbricks/lib": "workspace:*",
|
||||
"@formbricks/types": "workspace:*",
|
||||
"@formbricks/ui": "workspace:*",
|
||||
"@headlessui/react": "^2.1.1",
|
||||
"@headlessui/react": "^2.1.2",
|
||||
"@headlessui/tailwindcss": "^0.2.1",
|
||||
"@mapbox/rehype-prism": "^0.9.0",
|
||||
"@mdx-js/loader": "^3.0.1",
|
||||
"@mdx-js/react": "^3.0.1",
|
||||
"@next/mdx": "14.2.4",
|
||||
"@next/mdx": "14.2.5",
|
||||
"@paralleldrive/cuid2": "^2.2.2",
|
||||
"@sindresorhus/slugify": "^2.2.1",
|
||||
"@tailwindcss/typography": "^0.5.13",
|
||||
"acorn": "^8.12.0",
|
||||
"acorn": "^8.12.1",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"clsx": "^2.1.1",
|
||||
"fast-glob": "^3.3.2",
|
||||
"flexsearch": "^0.7.43",
|
||||
"framer-motion": "11.2.12",
|
||||
"framer-motion": "11.3.20",
|
||||
"lottie-web": "^5.12.2",
|
||||
"lucide": "^0.397.0",
|
||||
"lucide-react": "^0.397.0",
|
||||
"lucide": "^0.418.0",
|
||||
"lucide-react": "^0.418.0",
|
||||
"mdast-util-to-string": "^4.0.0",
|
||||
"mdx-annotations": "^0.1.4",
|
||||
"next": "14.2.4",
|
||||
"next": "14.2.5",
|
||||
"next-plausible": "^3.12.0",
|
||||
"next-seo": "^6.5.0",
|
||||
"next-sitemap": "^4.2.3",
|
||||
@@ -59,7 +59,7 @@
|
||||
"sharp": "^0.33.4",
|
||||
"shiki": "^0.14.7",
|
||||
"simple-functional-loader": "^1.2.1",
|
||||
"tailwindcss": "^3.4.4",
|
||||
"tailwindcss": "^3.4.7",
|
||||
"unist-util-filter": "^5.0.1",
|
||||
"unist-util-visit": "^5.0.0",
|
||||
"zustand": "^4.5.4"
|
||||
|
||||
@@ -21,6 +21,7 @@ const config: StorybookConfig = {
|
||||
getAbsolutePath("@storybook/addon-essentials"),
|
||||
getAbsolutePath("@chromatic-com/storybook"),
|
||||
getAbsolutePath("@storybook/addon-interactions"),
|
||||
getAbsolutePath("@storybook/addon-a11y"),
|
||||
],
|
||||
framework: {
|
||||
name: getAbsolutePath("@storybook/react-vite"),
|
||||
|
||||
@@ -12,29 +12,30 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@formbricks/ui": "workspace:*",
|
||||
"eslint-plugin-react-refresh": "^0.4.7",
|
||||
"eslint-plugin-react-refresh": "^0.4.9",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@chromatic-com/storybook": "^1.5.0",
|
||||
"@chromatic-com/storybook": "^1.6.1",
|
||||
"@formbricks/config-typescript": "workspace:*",
|
||||
"@storybook/addon-essentials": "^8.1.10",
|
||||
"@storybook/addon-interactions": "^8.1.10",
|
||||
"@storybook/addon-links": "^8.1.10",
|
||||
"@storybook/addon-onboarding": "^8.1.10",
|
||||
"@storybook/blocks": "^8.1.10",
|
||||
"@storybook/react": "^8.1.10",
|
||||
"@storybook/react-vite": "^8.1.10",
|
||||
"@storybook/test": "^8.1.10",
|
||||
"@typescript-eslint/eslint-plugin": "^7.13.1",
|
||||
"@typescript-eslint/parser": "^7.13.1",
|
||||
"@storybook/addon-a11y": "^8.2.7",
|
||||
"@storybook/addon-essentials": "^8.2.7",
|
||||
"@storybook/addon-interactions": "^8.2.7",
|
||||
"@storybook/addon-links": "^8.2.7",
|
||||
"@storybook/addon-onboarding": "^8.2.7",
|
||||
"@storybook/blocks": "^8.2.7",
|
||||
"@storybook/react": "^8.2.7",
|
||||
"@storybook/react-vite": "^8.2.7",
|
||||
"@storybook/test": "^8.2.7",
|
||||
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
||||
"@typescript-eslint/parser": "^8.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"esbuild": "^0.21.5",
|
||||
"esbuild": "^0.23.0",
|
||||
"eslint-plugin-storybook": "^0.8.0",
|
||||
"prop-types": "^15.8.1",
|
||||
"storybook": "^8.1.10",
|
||||
"tsup": "^8.1.0",
|
||||
"vite": "^5.3.1"
|
||||
"storybook": "^8.2.7",
|
||||
"tsup": "^8.2.3",
|
||||
"vite": "^5.3.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,17 +73,20 @@ export const ConnectWithFormbricks = ({
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<Image src={Lost} alt="lost" height={250} />
|
||||
<p className="pt-4 text-slate-400">Waiting for your signal...</p>
|
||||
<p className="animate-pulse pt-4 text-sm font-semibold text-slate-700">
|
||||
Waiting for your signal...
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
id="finishOnboarding"
|
||||
variant={widgetSetupCompleted ? "darkCTA" : "minimal"}
|
||||
className="text-slate-400 hover:text-slate-700"
|
||||
variant={widgetSetupCompleted ? "primary" : "minimal"}
|
||||
onClick={handleFinishOnboarding}
|
||||
EndIcon={ArrowRight}>
|
||||
{widgetSetupCompleted ? "Finish Onboarding" : "Skip"}
|
||||
{widgetSetupCompleted ? "Finish Onboarding" : "I don't know how to do it"}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -103,13 +103,9 @@ export const InviteOrganizationMember = ({ organization, environmentId }: Invite
|
||||
e.preventDefault();
|
||||
finishOnboarding();
|
||||
}}>
|
||||
Skip
|
||||
Not now
|
||||
</Button>
|
||||
<Button
|
||||
id="onboarding-inapp-invite-send-invite"
|
||||
variant="darkCTA"
|
||||
type={"submit"}
|
||||
loading={isSubmitting}>
|
||||
<Button id="onboarding-inapp-invite-send-invite" type={"submit"} loading={isSubmitting}>
|
||||
Invite
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -136,7 +136,7 @@ export const OnboardingSetupInstructions = ({
|
||||
<div className="mt-4 flex justify-between space-x-2">
|
||||
<Button
|
||||
id="onboarding-inapp-connect-copy-code"
|
||||
variant={widgetSetupCompleted ? "secondary" : "darkCTA"}
|
||||
variant={widgetSetupCompleted ? "secondary" : "primary"}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(
|
||||
channel === "app" ? htmlSnippetForAppSurveys : htmlSnippetForWebsiteSurveys
|
||||
|
||||
@@ -32,8 +32,8 @@ const Page = async ({ params }: InvitePageProps) => {
|
||||
return (
|
||||
<div className="flex min-h-full min-w-full flex-col items-center justify-center">
|
||||
<Header
|
||||
title="Invite your organization to help out"
|
||||
subtitle="Ask your tech-savvy co-worker to finish the setup:"
|
||||
title="Who is your favorite engineer?"
|
||||
subtitle="Invite your tech-savvy co-worker to help with the setup 🤓"
|
||||
/>
|
||||
<div className="space-y-4 text-center">
|
||||
<p className="text-4xl font-medium text-slate-800"></p>
|
||||
|
||||
@@ -38,7 +38,7 @@ const Page = async ({ params }: ConnectPageProps) => {
|
||||
<div className="flex min-h-full flex-col items-center justify-center py-10">
|
||||
<Header
|
||||
title={`Let's connect your ${customHeadline} with Formbricks`}
|
||||
subtitle="If you don't do it now, chances are low that you will ever do it!"
|
||||
subtitle="It takes less than 4 minutes, pinky promise!"
|
||||
/>
|
||||
<div className="space-y-4 text-center">
|
||||
<p className="text-4xl font-medium text-slate-800"></p>
|
||||
|
||||
@@ -4,8 +4,10 @@ export const getCustomHeadline = (channel: TProductConfigChannel, industry: TPro
|
||||
const combinations = {
|
||||
"website+eCommerce": "web shop",
|
||||
"website+saas": "landing page",
|
||||
"website+other": "website",
|
||||
"app+eCommerce": "shopping app",
|
||||
"app+saas": "SaaS app",
|
||||
"app+other": "app",
|
||||
};
|
||||
return combinations[`${channel}+${industry}`] || "app";
|
||||
return combinations[`${channel}+${industry}`] || "product";
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@ import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
|
||||
import { canUserAccessOrganization } from "@formbricks/lib/organization/auth";
|
||||
import { getOrganization } from "@formbricks/lib/organization/service";
|
||||
import { getUser } from "@formbricks/lib/user/service";
|
||||
import { AuthorizationError } from "@formbricks/types/errors";
|
||||
import { ToasterClient } from "@formbricks/ui/ToasterClient";
|
||||
|
||||
@@ -14,6 +15,11 @@ const ProductOnboardingLayout = async ({ children, params }) => {
|
||||
return redirect(`/auth/login`);
|
||||
}
|
||||
|
||||
const user = await getUser(session.user.id);
|
||||
if (!user) {
|
||||
throw new Error("User not found");
|
||||
}
|
||||
|
||||
const isAuthorized = await canUserAccessOrganization(session.user.id, params.organizationId);
|
||||
if (!isAuthorized) {
|
||||
throw AuthorizationError;
|
||||
@@ -31,6 +37,7 @@ const ProductOnboardingLayout = async ({ children, params }) => {
|
||||
<div className="flex-1 bg-slate-50">
|
||||
<PosthogIdentify
|
||||
session={session}
|
||||
user={user}
|
||||
organizationId={organization.id}
|
||||
organizationName={organization.name}
|
||||
organizationBilling={organization.billing}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
|
||||
import { CircleUserRoundIcon, EarthIcon, SendHorizonalIcon, XIcon } from "lucide-react";
|
||||
import { CircleUserRoundIcon, EarthIcon, LinkIcon, XIcon } from "lucide-react";
|
||||
import { getProducts } from "@formbricks/lib/product/service";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { Header } from "@formbricks/ui/Header";
|
||||
@@ -14,24 +14,24 @@ const Page = async ({ params }: ChannelPageProps) => {
|
||||
const channelOptions = [
|
||||
{
|
||||
title: "Public website",
|
||||
description: "Display surveys on public websites, well timed and targeted.",
|
||||
description: "Run well-timed pop-up surveys.",
|
||||
icon: EarthIcon,
|
||||
iconText: "Built for scale",
|
||||
href: `/organizations/${params.organizationId}/products/new/industry?channel=website`,
|
||||
},
|
||||
{
|
||||
title: "App with sign up",
|
||||
description: "Run highly targeted surveys with any user cohort.",
|
||||
description: "Run highly-targeted micro-surveys.",
|
||||
icon: CircleUserRoundIcon,
|
||||
iconText: "Enrich user profiles",
|
||||
href: `/organizations/${params.organizationId}/products/new/industry?channel=app`,
|
||||
},
|
||||
{
|
||||
channel: "link",
|
||||
title: "Anywhere online",
|
||||
description: "Create link and email surveys, reach your people anywhere.",
|
||||
icon: SendHorizonalIcon,
|
||||
iconText: "100% custom branding",
|
||||
title: "Link & email surveys",
|
||||
description: "Reach people anywhere online.",
|
||||
icon: LinkIcon,
|
||||
iconText: "Anywhere online",
|
||||
href: `/organizations/${params.organizationId}/products/new/industry?channel=link`,
|
||||
},
|
||||
];
|
||||
@@ -42,7 +42,7 @@ const Page = async ({ params }: ChannelPageProps) => {
|
||||
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
|
||||
<Header
|
||||
title="Where do you want to survey people?"
|
||||
subtitle="Get started with proven best practices 🚀"
|
||||
subtitle="Run surveys on public websites, in your app, or with shareable links & emails."
|
||||
/>
|
||||
<OnboardingOptionsContainer options={channelOptions} />
|
||||
{products.length >= 1 && (
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
|
||||
import { HeartIcon, MonitorIcon, ShoppingCart, XIcon } from "lucide-react";
|
||||
import { notFound } from "next/navigation";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
|
||||
import { getProducts } from "@formbricks/lib/product/service";
|
||||
import { TProductConfigChannel } from "@formbricks/types/product";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
@@ -27,32 +26,33 @@ const Page = async ({ params, searchParams }: IndustryPageProps) => {
|
||||
const industryOptions = [
|
||||
{
|
||||
title: "E-Commerce",
|
||||
description: "Implement proven best practices to understand why people buy.",
|
||||
description: "Understand why people buy.",
|
||||
icon: ShoppingCart,
|
||||
iconText: "B2B and B2C",
|
||||
href: `/organizations/${params.organizationId}/products/new/settings?channel=${channel}&industry=eCommerce`,
|
||||
},
|
||||
{
|
||||
title: "SaaS",
|
||||
description: "Gather contextualized feedback to improve product-market fit.",
|
||||
description: "Improve product-market fit.",
|
||||
icon: MonitorIcon,
|
||||
iconText: "Proven methods",
|
||||
href: `/organizations/${params.organizationId}/products/new/settings?channel=${channel}&industry=saas`,
|
||||
},
|
||||
{
|
||||
title: "Other",
|
||||
description: "Universal Formbricks experience with features for every industry.",
|
||||
description: "Listen to your customers.",
|
||||
icon: HeartIcon,
|
||||
iconText: "Customer insights",
|
||||
href: IS_FORMBRICKS_CLOUD
|
||||
? `/organizations/${params.organizationId}/products/new/survey?channel=${channel}&industry=other`
|
||||
: `/organizations/${params.organizationId}/products/new/settings?channel=${channel}&industry=other`,
|
||||
href: `/organizations/${params.organizationId}/products/new/settings?channel=${channel}&industry=other`,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
|
||||
<Header title="Which industry do you work for?" subtitle="Get started with proven best practices 🚀" />
|
||||
<Header
|
||||
title="Which industry do you work for?"
|
||||
subtitle="Get started with battle-tested best practices."
|
||||
/>
|
||||
<OnboardingOptionsContainer options={industryOptions} />
|
||||
{products.length >= 1 && (
|
||||
<Button
|
||||
|
||||
@@ -97,7 +97,7 @@ export const ProductSettings = ({
|
||||
<FormItem className="w-full space-y-4">
|
||||
<div>
|
||||
<FormLabel>Brand color</FormLabel>
|
||||
<FormDescription>Change the brand color of the survey.</FormDescription>
|
||||
<FormDescription>Match the main color of surveys with your brand.</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<div>
|
||||
@@ -118,9 +118,9 @@ export const ProductSettings = ({
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormItem className="w-full space-y-4">
|
||||
<div>
|
||||
<FormLabel>Product Name</FormLabel>
|
||||
<FormLabel>Product name</FormLabel>
|
||||
<FormDescription>
|
||||
What is your {getCustomHeadline(channel, industry)} called ?
|
||||
What is your {getCustomHeadline(channel, industry)} called?
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
@@ -128,7 +128,7 @@ export const ProductSettings = ({
|
||||
<Input
|
||||
value={field.value}
|
||||
onChange={(name) => field.onChange(name)}
|
||||
placeholder="Formbricks Merch Store"
|
||||
placeholder="e.g. Formbricks"
|
||||
className="bg-white"
|
||||
autoFocus={true}
|
||||
/>
|
||||
@@ -140,7 +140,7 @@ export const ProductSettings = ({
|
||||
/>
|
||||
|
||||
<div className="flex w-full justify-end">
|
||||
<Button variant="darkCTA" loading={isSubmitting} type="submit">
|
||||
<Button loading={isSubmitting} type="submit">
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -35,7 +35,7 @@ const Page = async ({ params, searchParams }: ProductSettingsPageProps) => {
|
||||
/>
|
||||
) : (
|
||||
<Header
|
||||
title={`You run ${startsWithVowel(customHeadline) ? "an " + customHeadline : "a " + customHeadline}, how exciting!`}
|
||||
title={`You maintain ${startsWithVowel(customHeadline) ? "an " + customHeadline : "a " + customHeadline}, how exciting!`}
|
||||
subtitle="Get 2x more responses matching surveys with your brand and UI"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { TProductConfigChannel } from "@formbricks/types/product";
|
||||
|
||||
interface OnboardingSurveyProps {
|
||||
organizationId: string;
|
||||
channel: TProductConfigChannel;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export const OnboardingSurvey = ({ organizationId, channel, userId }: OnboardingSurveyProps) => {
|
||||
const [isIFrameVisible, setIsIFrameVisible] = useState(false);
|
||||
const [fadeout, setFadeout] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const handleMessageEvent = (event: MessageEvent) => {
|
||||
if (event.data === "formbricksSurveyCompleted") {
|
||||
setFadeout(true); // Start fade-out
|
||||
setTimeout(() => {
|
||||
router.push(
|
||||
`/organizations/${organizationId}/products/new/settings?channel=${channel}&industry=other`
|
||||
);
|
||||
}, 800); // Delay the navigation until fade-out completes
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isIFrameVisible) {
|
||||
window.addEventListener("message", handleMessageEvent, false);
|
||||
return () => {
|
||||
window.removeEventListener("message", handleMessageEvent, false);
|
||||
};
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isIFrameVisible]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`overflow relative flex h-[100vh] flex-col items-center justify-center ${fadeout ? "opacity-0 transition-opacity duration-1000" : "opacity-100"}`}>
|
||||
<iframe
|
||||
onLoad={() => setIsIFrameVisible(true)}
|
||||
src={`https://app.formbricks.com/s/clxcwr22p0cwlpvgekzdab2x5?userId=${userId}`}
|
||||
className="absolute left-0 top-0 h-full w-full overflow-visible border-0"></iframe>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,32 +0,0 @@
|
||||
import { OnboardingSurvey } from "@/app/(app)/(onboarding)/organizations/[organizationId]/products/new/survey/components/OnboardingSurvey";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import { TProductConfigChannel, TProductConfigIndustry } from "@formbricks/types/product";
|
||||
|
||||
interface OnboardingSurveyPageProps {
|
||||
params: {
|
||||
organizationId: string;
|
||||
};
|
||||
searchParams: {
|
||||
channel?: TProductConfigChannel;
|
||||
industry?: TProductConfigIndustry;
|
||||
};
|
||||
}
|
||||
|
||||
const Page = async ({ params, searchParams }: OnboardingSurveyPageProps) => {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) {
|
||||
return redirect(`/auth/login`);
|
||||
}
|
||||
|
||||
const channel = searchParams.channel;
|
||||
const industry = searchParams.industry;
|
||||
if (!channel || !industry) return notFound();
|
||||
|
||||
return (
|
||||
<OnboardingSurvey organizationId={params.organizationId} channel={channel} userId={session.user.id} />
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
@@ -7,6 +7,7 @@ import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import { INVITE_DISABLED } from "@formbricks/lib/constants";
|
||||
import { inviteUser } from "@formbricks/lib/invite/service";
|
||||
import { verifyUserRoleAccess } from "@formbricks/lib/organization/auth";
|
||||
import { getUser } from "@formbricks/lib/user/service";
|
||||
import { AuthenticationError } from "@formbricks/types/errors";
|
||||
import { TMembershipRole } from "@formbricks/types/memberships";
|
||||
|
||||
@@ -22,6 +23,12 @@ export const inviteOrganizationMemberAction = async (
|
||||
throw new AuthenticationError("Not authenticated");
|
||||
}
|
||||
|
||||
const user = await getUser(session.user.id);
|
||||
|
||||
if (!user) {
|
||||
throw new Error("User not found");
|
||||
}
|
||||
|
||||
const isUserAuthorized = await hasOrganizationAuthority(session.user.id, organizationId);
|
||||
|
||||
if (INVITE_DISABLED) {
|
||||
@@ -50,7 +57,7 @@ export const inviteOrganizationMemberAction = async (
|
||||
await sendInviteMemberEmail(
|
||||
invite.id,
|
||||
email,
|
||||
session.user.name ?? "",
|
||||
user.name ?? "",
|
||||
"",
|
||||
true, // is onboarding invite
|
||||
inviteMessage
|
||||
|
||||
@@ -7,6 +7,7 @@ import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
|
||||
import { getEnvironment } from "@formbricks/lib/environment/service";
|
||||
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
|
||||
import { getUser } from "@formbricks/lib/user/service";
|
||||
import { AuthorizationError } from "@formbricks/types/errors";
|
||||
import { DevEnvironmentBanner } from "@formbricks/ui/DevEnvironmentBanner";
|
||||
import { ToasterClient } from "@formbricks/ui/ToasterClient";
|
||||
@@ -16,6 +17,12 @@ const EnvLayout = async ({ children, params }) => {
|
||||
if (!session || !session.user) {
|
||||
return redirect(`/auth/login`);
|
||||
}
|
||||
|
||||
const user = await getUser(session.user.id);
|
||||
if (!user) {
|
||||
throw new Error("User not found");
|
||||
}
|
||||
|
||||
const hasAccess = await hasUserEnvironmentAccess(session.user.id, params.environmentId);
|
||||
if (!hasAccess) {
|
||||
throw new AuthorizationError("Not authorized");
|
||||
@@ -37,12 +44,13 @@ const EnvLayout = async ({ children, params }) => {
|
||||
<ResponseFilterProvider>
|
||||
<PosthogIdentify
|
||||
session={session}
|
||||
user={user}
|
||||
environmentId={params.environmentId}
|
||||
organizationId={organization.id}
|
||||
organizationName={organization.name}
|
||||
organizationBilling={organization.billing}
|
||||
/>
|
||||
<FormbricksClient session={session} />
|
||||
<FormbricksClient session={session} userEmail={user.email} />
|
||||
<ToasterClient />
|
||||
<div className="flex h-screen flex-col">
|
||||
<DevEnvironmentBanner environment={environment} />
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
|
||||
interface AddEndingCardButtonProps {
|
||||
localSurvey: TSurvey;
|
||||
setLocalSurvey: (survey: TSurvey) => void;
|
||||
addEndingCard: (index: number) => void;
|
||||
}
|
||||
|
||||
export const AddEndingCardButton = ({ localSurvey, addEndingCard }: AddEndingCardButtonProps) => {
|
||||
return (
|
||||
<div
|
||||
className="group inline-flex rounded-lg border border-slate-300 bg-slate-50 hover:cursor-pointer hover:bg-white"
|
||||
onClick={() => addEndingCard(localSurvey.endings.length)}>
|
||||
<div className="flex w-10 items-center justify-center rounded-l-lg bg-slate-400 transition-all duration-300 ease-in-out group-hover:bg-slate-500 group-aria-expanded:rounded-bl-none group-aria-expanded:rounded-br">
|
||||
<PlusIcon className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
<div className="px-4 py-3 text-sm">
|
||||
<p className="font-semibold">Add Ending</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -21,17 +21,17 @@ export const AddQuestionButton = ({ addQuestion, product }: AddQuestionButtonPro
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
className={cn(
|
||||
open ? "scale-100 shadow-lg" : "scale-97 shadow-md",
|
||||
"group w-full space-y-2 rounded-lg border border-slate-300 bg-white transition-all duration-300 ease-in-out hover:scale-100 hover:cursor-pointer hover:bg-slate-50"
|
||||
open ? "shadow-lg" : "shadow-md",
|
||||
"group w-full space-y-2 rounded-lg border border-slate-300 bg-white duration-300 hover:cursor-pointer hover:bg-slate-50"
|
||||
)}>
|
||||
<Collapsible.CollapsibleTrigger asChild className="group h-full w-full">
|
||||
<div className="inline-flex">
|
||||
<div className="bg-brand-dark flex w-10 items-center justify-center rounded-l-lg group-aria-expanded:rounded-bl-none group-aria-expanded:rounded-br">
|
||||
<PlusIcon className="h-6 w-6 text-white" />
|
||||
<PlusIcon className="h-5 w-5 text-white" />
|
||||
</div>
|
||||
<div className="px-4 py-3">
|
||||
<p className="font-semibold">Add Question</p>
|
||||
<p className="mt-1 text-sm text-slate-500">Add a new question to your survey</p>
|
||||
<p className="text-sm font-semibold">Add Question</p>
|
||||
<p className="mt-1 text-xs text-slate-500">Add a new question to your survey</p>
|
||||
</div>
|
||||
</div>
|
||||
</Collapsible.CollapsibleTrigger>
|
||||
@@ -41,7 +41,7 @@ export const AddQuestionButton = ({ addQuestion, product }: AddQuestionButtonPro
|
||||
<button
|
||||
type="button"
|
||||
key={questionType.id}
|
||||
className="mx-2 inline-flex items-center rounded p-0.5 px-4 py-2 font-medium text-slate-700 last:mb-2 hover:bg-slate-100 hover:text-slate-800"
|
||||
className="mx-2 inline-flex items-center rounded p-0.5 px-4 py-2 text-sm font-medium text-slate-700 last:mb-2 hover:bg-slate-100 hover:text-slate-800"
|
||||
onClick={() => {
|
||||
addQuestion({
|
||||
...universalQuestionPresets,
|
||||
@@ -51,7 +51,7 @@ export const AddQuestionButton = ({ addQuestion, product }: AddQuestionButtonPro
|
||||
});
|
||||
setOpen(false);
|
||||
}}>
|
||||
<questionType.icon className="text-brand-dark -ml-0.5 mr-2 h-5 w-5" aria-hidden="true" />
|
||||
<questionType.icon className="text-brand-dark -ml-0.5 mr-2 h-4 w-4" aria-hidden="true" />
|
||||
{questionType.label}
|
||||
</button>
|
||||
))}
|
||||
|
||||
@@ -6,8 +6,16 @@ import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import { TSurvey, TSurveyCTAQuestion } from "@formbricks/types/surveys/types";
|
||||
import { Input } from "@formbricks/ui/Input";
|
||||
import { Label } from "@formbricks/ui/Label";
|
||||
import { OptionsSwitch } from "@formbricks/ui/OptionsSwitch";
|
||||
import { QuestionFormInput } from "@formbricks/ui/QuestionFormInput";
|
||||
import { RadioGroup, RadioGroupItem } from "@formbricks/ui/RadioGroup";
|
||||
|
||||
const options = [
|
||||
{
|
||||
value: "internal",
|
||||
label: "Button to continue in survey",
|
||||
},
|
||||
{ value: "external", label: "Button to link to external URL" },
|
||||
];
|
||||
|
||||
interface CTAQuestionFormProps {
|
||||
localSurvey: TSurvey;
|
||||
@@ -66,25 +74,13 @@ export const CTAQuestionForm = ({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RadioGroup
|
||||
className="mt-3 flex"
|
||||
defaultValue="internal"
|
||||
value={question.buttonExternal ? "external" : "internal"}
|
||||
onValueChange={(e) => updateQuestion(questionIdx, { buttonExternal: e === "external" })}>
|
||||
<div className="flex items-center space-x-2 rounded-lg border border-slate-200 p-3 dark:border-slate-500">
|
||||
<RadioGroupItem value="internal" id="internal" className="bg-slate-50" />
|
||||
<Label htmlFor="internal" className="cursor-pointer dark:text-slate-200">
|
||||
Button to continue in survey
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 rounded-lg border border-slate-200 p-3 dark:border-slate-500">
|
||||
<RadioGroupItem value="external" id="external" className="bg-slate-50" />
|
||||
<Label htmlFor="external" className="cursor-pointer dark:text-slate-200">
|
||||
Button to link to external URL
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
<div className="mt-3">
|
||||
<OptionsSwitch
|
||||
options={options}
|
||||
currentOption={question.buttonExternal ? "external" : "internal"}
|
||||
handleOptionChange={(e) => updateQuestion(questionIdx, { buttonExternal: e === "external" })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex justify-between gap-8">
|
||||
<div className="flex w-full space-x-2">
|
||||
|
||||
@@ -3,8 +3,8 @@ import { useEffect, useState } from "react";
|
||||
import { createI18nString, extractLanguageCodes } from "@formbricks/lib/i18n/utils";
|
||||
import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import { TSurvey, TSurveyCalQuestion } from "@formbricks/types/surveys/types";
|
||||
import { AdvancedOptionToggle } from "@formbricks/ui/AdvancedOptionToggle";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { Checkbox } from "@formbricks/ui/Checkbox";
|
||||
import { Input } from "@formbricks/ui/Input";
|
||||
import { Label } from "@formbricks/ui/Label";
|
||||
import { QuestionFormInput } from "@formbricks/ui/QuestionFormInput";
|
||||
@@ -37,6 +37,8 @@ export const CalQuestionForm = ({
|
||||
useEffect(() => {
|
||||
if (!isCalHostEnabled) {
|
||||
updateQuestion(questionIdx, { calHost: undefined });
|
||||
} else {
|
||||
updateQuestion(questionIdx, { calHost: question.calHost ?? "cal.com" });
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -91,9 +93,9 @@ export const CalQuestionForm = ({
|
||||
Add Description
|
||||
</Button>
|
||||
)}
|
||||
<div className="mt-3 flex flex-col gap-4">
|
||||
<div className="mt-3 flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-3">
|
||||
<Label htmlFor="calUserName">Add your Cal.com username or username/event</Label>
|
||||
<Label htmlFor="calUserName">Cal.com username or username/event</Label>
|
||||
<div>
|
||||
<Input
|
||||
id="calUserName"
|
||||
@@ -104,29 +106,28 @@ export const CalQuestionForm = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="calHost"
|
||||
checked={isCalHostEnabled}
|
||||
onCheckedChange={(checked: boolean) => setIsCalHostEnabled(checked)}
|
||||
/>
|
||||
<Label htmlFor="calHost">Do you have a self-hosted Cal.com instance?</Label>
|
||||
</div>
|
||||
|
||||
{isCalHostEnabled && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="calHost">Enter the hostname of your self-hosted Cal.com instance</Label>
|
||||
<AdvancedOptionToggle
|
||||
isChecked={isCalHostEnabled}
|
||||
onToggle={(checked: boolean) => setIsCalHostEnabled(checked)}
|
||||
htmlId="calHost"
|
||||
description="Needed for a self-hosted Cal.com instance"
|
||||
childBorder
|
||||
title="Custom hostname"
|
||||
customContainerClass="p-0">
|
||||
<div className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="calHost">Hostname</Label>
|
||||
<Input
|
||||
id="calHost"
|
||||
name="calHost"
|
||||
placeholder="cal.com"
|
||||
placeholder="my-cal-instance.com"
|
||||
value={question.calHost}
|
||||
className="bg-white"
|
||||
onChange={(e) => updateQuestion(questionIdx, { calHost: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AdvancedOptionToggle>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -249,7 +249,7 @@ export const CreateNewActionTab = ({
|
||||
<Button type="button" variant="minimal" onClick={resetAllStates}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="darkCTA" type="submit" loading={isSubmitting}>
|
||||
<Button type="submit" loading={isSubmitting}>
|
||||
Create action
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -4,8 +4,8 @@ import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import { TSurvey, TSurveyDateQuestion } from "@formbricks/types/surveys/types";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { Label } from "@formbricks/ui/Label";
|
||||
import { OptionsSwitch } from "@formbricks/ui/OptionsSwitch";
|
||||
import { QuestionFormInput } from "@formbricks/ui/QuestionFormInput";
|
||||
import { OptionsSwitcher } from "@formbricks/ui/QuestionTypeSelector";
|
||||
|
||||
interface IDateQuestionFormProps {
|
||||
localSurvey: TSurvey;
|
||||
@@ -100,10 +100,10 @@ export const DateQuestionForm = ({
|
||||
<div className="mt-3">
|
||||
<Label htmlFor="questionType">Date Format</Label>
|
||||
<div className="mt-2 flex items-center">
|
||||
<OptionsSwitcher
|
||||
<OptionsSwitch
|
||||
options={dateOptions}
|
||||
currentOption={question.format}
|
||||
handleTypeChange={(value: "M-d-y" | "d-M-y" | "y-M-d") =>
|
||||
handleOptionChange={(value: "M-d-y" | "d-M-y" | "y-M-d") =>
|
||||
updateQuestion(questionIdx, { format: value })
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
"use client";
|
||||
|
||||
import { EditorCardMenu } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/EditorCardMenu";
|
||||
import { EndScreenForm } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/EndScreenForm";
|
||||
import { RedirectUrlForm } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/RedirectUrlForm";
|
||||
import { formatTextWithSlashes } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/util";
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
import { GripIcon } from "lucide-react";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { recallToHeadline } from "@formbricks/lib/utils/recall";
|
||||
import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import { TOrganizationBillingPlan } from "@formbricks/types/organizations";
|
||||
import { TSurvey, TSurveyEndScreenCard, TSurveyRedirectUrlCard } from "@formbricks/types/surveys/types";
|
||||
import { OptionsSwitch } from "@formbricks/ui/OptionsSwitch";
|
||||
import { TooltipRenderer } from "@formbricks/ui/Tooltip";
|
||||
|
||||
interface EditEndingCardProps {
|
||||
localSurvey: TSurvey;
|
||||
endingCardIndex: number;
|
||||
setLocalSurvey: React.Dispatch<React.SetStateAction<TSurvey>>;
|
||||
setActiveQuestionId: (id: string | null) => void;
|
||||
activeQuestionId: string | null;
|
||||
isInvalid: boolean;
|
||||
selectedLanguageCode: string;
|
||||
setSelectedLanguageCode: (languageCode: string) => void;
|
||||
attributeClasses: TAttributeClass[];
|
||||
plan: TOrganizationBillingPlan;
|
||||
addEndingCard: (index: number) => void;
|
||||
isFormbricksCloud: boolean;
|
||||
}
|
||||
|
||||
const endingCardTypes = [
|
||||
{ value: "endScreen", label: "Ending card" },
|
||||
{ value: "redirectToUrl", label: "Redirect to Url" },
|
||||
];
|
||||
|
||||
export const EditEndingCard = ({
|
||||
localSurvey,
|
||||
endingCardIndex,
|
||||
setLocalSurvey,
|
||||
setActiveQuestionId,
|
||||
activeQuestionId,
|
||||
isInvalid,
|
||||
selectedLanguageCode,
|
||||
setSelectedLanguageCode,
|
||||
attributeClasses,
|
||||
plan,
|
||||
addEndingCard,
|
||||
isFormbricksCloud,
|
||||
}: EditEndingCardProps) => {
|
||||
const endingCard = localSurvey.endings[endingCardIndex];
|
||||
const isRedirectToUrlDisabled = isFormbricksCloud
|
||||
? plan === "free" && endingCard.type !== "redirectToUrl"
|
||||
: false;
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: endingCard.id,
|
||||
});
|
||||
let open = activeQuestionId === endingCard.id;
|
||||
|
||||
const setOpen = (e) => {
|
||||
if (e) {
|
||||
setActiveQuestionId(endingCard.id);
|
||||
} else {
|
||||
setActiveQuestionId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const updateSurvey = (data: Partial<TSurveyEndScreenCard> | Partial<TSurveyRedirectUrlCard>) => {
|
||||
setLocalSurvey((prevSurvey) => {
|
||||
const updatedEndings = prevSurvey.endings.map((ending, idx) =>
|
||||
idx === endingCardIndex ? { ...ending, ...data } : ending
|
||||
);
|
||||
return { ...prevSurvey, endings: updatedEndings };
|
||||
});
|
||||
};
|
||||
|
||||
const deleteEndingCard = () => {
|
||||
setLocalSurvey((prevSurvey) => {
|
||||
const updatedEndings = prevSurvey.endings.filter((_, index) => index !== endingCardIndex);
|
||||
return { ...prevSurvey, endings: updatedEndings };
|
||||
});
|
||||
};
|
||||
|
||||
const style = {
|
||||
transition: transition ?? "transform 100ms ease",
|
||||
transform: CSS.Translate.toString(transform),
|
||||
zIndex: isDragging ? 10 : 1,
|
||||
};
|
||||
|
||||
const duplicateEndingCard = () => {
|
||||
setLocalSurvey((prevSurvey) => {
|
||||
const endingToDuplicate = prevSurvey.endings[endingCardIndex];
|
||||
const duplicatedEndingCard = {
|
||||
...endingToDuplicate,
|
||||
id: createId(),
|
||||
};
|
||||
const updatedEndings = [
|
||||
...prevSurvey.endings.slice(0, endingCardIndex + 1),
|
||||
duplicatedEndingCard,
|
||||
...prevSurvey.endings.slice(endingCardIndex + 1),
|
||||
];
|
||||
return { ...prevSurvey, endings: updatedEndings };
|
||||
});
|
||||
};
|
||||
|
||||
const moveEndingCard = (index: number, up: boolean) => {
|
||||
setLocalSurvey((prevSurvey) => {
|
||||
const newEndings = [...prevSurvey.endings];
|
||||
const [movedEnding] = newEndings.splice(index, 1);
|
||||
newEndings.splice(up ? index - 1 : index + 1, 0, movedEnding);
|
||||
return { ...prevSurvey, endings: newEndings };
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(open ? "shadow-lg" : "shadow-md", "group z-20 flex flex-row rounded-lg bg-white")}
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
id={endingCard.id}>
|
||||
<div
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
className={cn(
|
||||
open ? "bg-slate-50" : "",
|
||||
"flex w-10 flex-col items-center justify-between rounded-l-lg border-b border-l border-t py-2 group-aria-expanded:rounded-bl-none",
|
||||
isInvalid ? "bg-red-400" : "bg-white group-hover:bg-slate-50"
|
||||
)}>
|
||||
<p className="mt-3">{endingCard.type === "endScreen" ? "🙏" : "↪️"}</p>
|
||||
<button className="opacity-0 transition-all duration-300 hover:cursor-move group-hover:opacity-100">
|
||||
<GripIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<Collapsible.Root
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
className="flex-1 rounded-r-lg border border-slate-200 transition-all duration-300 ease-in-out">
|
||||
<Collapsible.CollapsibleTrigger
|
||||
asChild
|
||||
className="flex cursor-pointer justify-between rounded-r-lg p-5 hover:bg-slate-50">
|
||||
<div>
|
||||
<div className="inline-flex">
|
||||
<div>
|
||||
<p className="text-sm font-semibold">
|
||||
{endingCard.type === "endScreen" &&
|
||||
(endingCard.headline &&
|
||||
recallToHeadline(
|
||||
endingCard.headline,
|
||||
localSurvey,
|
||||
true,
|
||||
selectedLanguageCode,
|
||||
attributeClasses
|
||||
)[selectedLanguageCode]
|
||||
? formatTextWithSlashes(
|
||||
recallToHeadline(
|
||||
endingCard.headline,
|
||||
localSurvey,
|
||||
true,
|
||||
selectedLanguageCode,
|
||||
attributeClasses
|
||||
)[selectedLanguageCode]
|
||||
)
|
||||
: "Ending card")}
|
||||
{endingCard.type === "redirectToUrl" && (endingCard.label || "Redirect to Url")}
|
||||
</p>
|
||||
{!open && (
|
||||
<p className="mt-1 truncate text-xs text-slate-500">
|
||||
{endingCard.type === "endScreen" ? "Ending card" : "Redirect to Url"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
<EditorCardMenu
|
||||
survey={localSurvey}
|
||||
cardIdx={endingCardIndex}
|
||||
lastCard={endingCardIndex === localSurvey.endings.length - 1}
|
||||
duplicateCard={duplicateEndingCard}
|
||||
deleteCard={deleteEndingCard}
|
||||
moveCard={moveEndingCard}
|
||||
card={endingCard}
|
||||
updateCard={() => {}}
|
||||
addCard={addEndingCard}
|
||||
cardType="ending"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Collapsible.CollapsibleTrigger>
|
||||
<Collapsible.CollapsibleContent className="mt-3 px-4 pb-6">
|
||||
<TooltipRenderer
|
||||
shouldRender={endingCard.type === "endScreen" && isRedirectToUrlDisabled}
|
||||
tooltipContent={"Redirect To Url is not available on free plan"}
|
||||
triggerClass="w-full">
|
||||
<OptionsSwitch
|
||||
options={endingCardTypes}
|
||||
currentOption={endingCard.type}
|
||||
handleOptionChange={() => {
|
||||
if (endingCard.type === "endScreen") {
|
||||
updateSurvey({ type: "redirectToUrl" });
|
||||
} else {
|
||||
updateSurvey({ type: "endScreen" });
|
||||
}
|
||||
}}
|
||||
disabled={isRedirectToUrlDisabled}
|
||||
/>
|
||||
</TooltipRenderer>
|
||||
{endingCard.type === "endScreen" && (
|
||||
<EndScreenForm
|
||||
localSurvey={localSurvey}
|
||||
endingCardIndex={endingCardIndex}
|
||||
isInvalid={isInvalid}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
attributeClasses={attributeClasses}
|
||||
updateSurvey={updateSurvey}
|
||||
endingCard={endingCard}
|
||||
/>
|
||||
)}
|
||||
{endingCard.type === "redirectToUrl" && (
|
||||
<RedirectUrlForm endingCard={endingCard} updateSurvey={updateSurvey} />
|
||||
)}
|
||||
</Collapsible.CollapsibleContent>
|
||||
</Collapsible.Root>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,200 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
import { useState } from "react";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
|
||||
import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { Input } from "@formbricks/ui/Input";
|
||||
import { Label } from "@formbricks/ui/Label";
|
||||
import { QuestionFormInput } from "@formbricks/ui/QuestionFormInput";
|
||||
import { Switch } from "@formbricks/ui/Switch";
|
||||
|
||||
interface EditThankYouCardProps {
|
||||
localSurvey: TSurvey;
|
||||
setLocalSurvey: (survey: TSurvey) => void;
|
||||
setActiveQuestionId: (id: string | null) => void;
|
||||
activeQuestionId: string | null;
|
||||
isInvalid: boolean;
|
||||
selectedLanguageCode: string;
|
||||
setSelectedLanguageCode: (languageCode: string) => void;
|
||||
attributeClasses: TAttributeClass[];
|
||||
}
|
||||
|
||||
export const EditThankYouCard = ({
|
||||
localSurvey,
|
||||
setLocalSurvey,
|
||||
setActiveQuestionId,
|
||||
activeQuestionId,
|
||||
isInvalid,
|
||||
selectedLanguageCode,
|
||||
setSelectedLanguageCode,
|
||||
attributeClasses,
|
||||
}: EditThankYouCardProps) => {
|
||||
// const [open, setOpen] = useState(false);
|
||||
let open = activeQuestionId == "end";
|
||||
const [showThankYouCardCTA, setshowThankYouCardCTA] = useState<boolean>(
|
||||
getLocalizedValue(localSurvey.thankYouCard.buttonLabel, "default") || localSurvey.thankYouCard.buttonLink
|
||||
? true
|
||||
: false
|
||||
);
|
||||
const setOpen = (e) => {
|
||||
if (e) {
|
||||
setActiveQuestionId("end");
|
||||
} else {
|
||||
setActiveQuestionId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const updateSurvey = (data) => {
|
||||
const updatedSurvey = {
|
||||
...localSurvey,
|
||||
thankYouCard: {
|
||||
...localSurvey.thankYouCard,
|
||||
...data,
|
||||
},
|
||||
};
|
||||
setLocalSurvey(updatedSurvey);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
open ? "scale-100 shadow-lg" : "scale-97 shadow-md",
|
||||
"group z-20 flex flex-row rounded-lg bg-white transition-transform duration-300 ease-in-out"
|
||||
)}>
|
||||
<div
|
||||
className={cn(
|
||||
open ? "bg-slate-50" : "",
|
||||
"flex w-10 items-center justify-center rounded-l-lg border-b border-l border-t group-aria-expanded:rounded-bl-none",
|
||||
isInvalid ? "bg-red-400" : "bg-white group-hover:bg-slate-50"
|
||||
)}>
|
||||
<p>🙏</p>
|
||||
</div>
|
||||
<Collapsible.Root
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
className="flex-1 rounded-r-lg border border-slate-200 transition-all duration-300 ease-in-out">
|
||||
<Collapsible.CollapsibleTrigger
|
||||
asChild
|
||||
className="flex cursor-pointer justify-between p-4 hover:bg-slate-50">
|
||||
<div>
|
||||
<div className="inline-flex">
|
||||
<div>
|
||||
<p className="text-sm font-semibold">Thank You Card</p>
|
||||
{!open && (
|
||||
<p className="mt-1 truncate text-xs text-slate-500">
|
||||
{localSurvey?.thankYouCard?.enabled ? "Shown" : "Hidden"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{localSurvey.type !== "link" && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Label htmlFor="thank-you-toggle">Show</Label>
|
||||
|
||||
<Switch
|
||||
id="thank-you-toggle"
|
||||
checked={localSurvey?.thankYouCard?.enabled}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
updateSurvey({ enabled: !localSurvey.thankYouCard?.enabled });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Collapsible.CollapsibleTrigger>
|
||||
<Collapsible.CollapsibleContent className="px-4 pb-6">
|
||||
<form>
|
||||
<QuestionFormInput
|
||||
id="headline"
|
||||
label="Note*"
|
||||
value={localSurvey?.thankYouCard?.headline}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={localSurvey.questions.length}
|
||||
isInvalid={isInvalid}
|
||||
updateSurvey={updateSurvey}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
attributeClasses={attributeClasses}
|
||||
/>
|
||||
|
||||
<QuestionFormInput
|
||||
id="subheader"
|
||||
value={localSurvey.thankYouCard.subheader}
|
||||
label={"Description"}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={localSurvey.questions.length}
|
||||
isInvalid={isInvalid}
|
||||
updateSurvey={updateSurvey}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
attributeClasses={attributeClasses}
|
||||
/>
|
||||
<div className="mt-4">
|
||||
<div className="flex items-center space-x-1">
|
||||
<Switch
|
||||
id="showButton"
|
||||
checked={showThankYouCardCTA}
|
||||
onCheckedChange={() => {
|
||||
if (showThankYouCardCTA) {
|
||||
updateSurvey({ buttonLabel: undefined, buttonLink: undefined });
|
||||
} else {
|
||||
updateSurvey({
|
||||
buttonLabel: { default: "Create your own Survey" },
|
||||
buttonLink: "https://formbricks.com/signup",
|
||||
});
|
||||
}
|
||||
setshowThankYouCardCTA(!showThankYouCardCTA);
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="showButton" className="cursor-pointer">
|
||||
<div className="ml-2">
|
||||
<h3 className="text-sm font-semibold text-slate-700">Show Button</h3>
|
||||
<p className="text-xs font-normal text-slate-500">
|
||||
Send your respondents to a page of your choice.
|
||||
</p>
|
||||
</div>
|
||||
</Label>
|
||||
</div>
|
||||
{showThankYouCardCTA && (
|
||||
<div className="border-1 mt-4 space-y-4 rounded-md border bg-slate-100 p-4 pt-2">
|
||||
<div className="space-y-2">
|
||||
<QuestionFormInput
|
||||
id="buttonLabel"
|
||||
label="Button Label"
|
||||
placeholder="Create your own Survey"
|
||||
className="bg-white"
|
||||
value={localSurvey.thankYouCard.buttonLabel}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={localSurvey.questions.length}
|
||||
isInvalid={isInvalid}
|
||||
updateSurvey={updateSurvey}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
attributeClasses={attributeClasses}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Button Link</Label>
|
||||
<Input
|
||||
id="buttonLink"
|
||||
name="buttonLink"
|
||||
className="bg-white"
|
||||
placeholder="https://formbricks.com/signup"
|
||||
value={localSurvey.thankYouCard.buttonLink}
|
||||
onChange={(e) => updateSurvey({ buttonLink: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</Collapsible.CollapsibleContent>
|
||||
</Collapsible.Root>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -6,7 +6,7 @@ import { useState } from "react";
|
||||
import { LocalizedEditor } from "@formbricks/ee/multi-language/components/localized-editor";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TSurvey, TSurveyWelcomeCard } from "@formbricks/types/surveys/types";
|
||||
import { FileInput } from "@formbricks/ui/FileInput";
|
||||
import { Label } from "@formbricks/ui/Label";
|
||||
import { QuestionFormInput } from "@formbricks/ui/QuestionFormInput";
|
||||
@@ -48,7 +48,7 @@ export const EditWelcomeCard = ({
|
||||
}
|
||||
};
|
||||
|
||||
const updateSurvey = (data: Partial<TSurvey["welcomeCard"]>) => {
|
||||
const updateSurvey = (data: Partial<TSurveyWelcomeCard>) => {
|
||||
setLocalSurvey({
|
||||
...localSurvey,
|
||||
welcomeCard: {
|
||||
@@ -59,11 +59,7 @@ export const EditWelcomeCard = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
open ? "scale-100 shadow-lg" : "scale-97 shadow-md",
|
||||
"group flex flex-row rounded-lg bg-white transition-transform duration-300 ease-in-out"
|
||||
)}>
|
||||
<div className={cn(open ? "shadow-lg" : "shadow-md", "group flex flex-row rounded-lg bg-white")}>
|
||||
<div
|
||||
className={cn(
|
||||
open ? "bg-slate-50" : "",
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
"use client";
|
||||
|
||||
import { QUESTIONS_ICON_MAP, QUESTIONS_NAME_MAP, getQuestionDefaults } from "@/app/lib/questions";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { ArrowDownIcon, ArrowUpIcon, CopyIcon, EllipsisIcon, TrashIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { TProduct } from "@formbricks/types/product";
|
||||
import {
|
||||
TSurvey,
|
||||
TSurveyEndScreenCard,
|
||||
TSurveyQuestion,
|
||||
TSurveyQuestionTypeEnum,
|
||||
TSurveyRedirectUrlCard,
|
||||
ZSurveyQuestion,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { ConfirmationModal } from "@formbricks/ui/ConfirmationModal";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "@formbricks/ui/DropdownMenu";
|
||||
|
||||
interface EditorCardMenuProps {
|
||||
survey: TSurvey;
|
||||
cardIdx: number;
|
||||
lastCard: boolean;
|
||||
duplicateCard: (cardIdx: number) => void;
|
||||
deleteCard: (cardIdx: number) => void;
|
||||
moveCard: (cardIdx: number, up: boolean) => void;
|
||||
card: TSurveyQuestion | TSurveyEndScreenCard | TSurveyRedirectUrlCard;
|
||||
updateCard: (cardIdx: number, updatedAttributes: any) => void;
|
||||
addCard: (question: any, index?: number) => void;
|
||||
cardType: "question" | "ending";
|
||||
product?: TProduct;
|
||||
}
|
||||
|
||||
export const EditorCardMenu = ({
|
||||
survey,
|
||||
cardIdx,
|
||||
lastCard,
|
||||
duplicateCard,
|
||||
deleteCard,
|
||||
moveCard,
|
||||
product,
|
||||
card,
|
||||
updateCard,
|
||||
addCard,
|
||||
cardType,
|
||||
}: EditorCardMenuProps) => {
|
||||
const [logicWarningModal, setLogicWarningModal] = useState(false);
|
||||
const [changeToType, setChangeToType] = useState(
|
||||
card.type !== "endScreen" && card.type !== "redirectToUrl" ? card.type : undefined
|
||||
);
|
||||
const isDeleteDisabled =
|
||||
cardType === "question"
|
||||
? survey.questions.length === 1
|
||||
: survey.type === "link" && survey.endings.length === 1;
|
||||
|
||||
const changeQuestionType = (type?: TSurveyQuestionTypeEnum) => {
|
||||
const parseResult = ZSurveyQuestion.safeParse(card);
|
||||
if (parseResult.success && type) {
|
||||
const question = parseResult.data;
|
||||
const { headline, required, subheader, imageUrl, videoUrl, buttonLabel, backButtonLabel } = question;
|
||||
|
||||
const questionDefaults = getQuestionDefaults(type, product);
|
||||
|
||||
// if going from single select to multi select or vice versa, we need to keep the choices as well
|
||||
|
||||
if (
|
||||
(type === TSurveyQuestionTypeEnum.MultipleChoiceSingle &&
|
||||
question.type === TSurveyQuestionTypeEnum.MultipleChoiceMulti) ||
|
||||
(type === TSurveyQuestionTypeEnum.MultipleChoiceMulti &&
|
||||
question.type === TSurveyQuestionTypeEnum.MultipleChoiceSingle)
|
||||
) {
|
||||
updateCard(cardIdx, {
|
||||
choices: question.choices,
|
||||
type,
|
||||
logic: undefined,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
updateCard(cardIdx, {
|
||||
...questionDefaults,
|
||||
type,
|
||||
headline,
|
||||
subheader,
|
||||
required,
|
||||
imageUrl,
|
||||
videoUrl,
|
||||
buttonLabel,
|
||||
backButtonLabel,
|
||||
logic: undefined,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const addQuestionCardBelow = (type: TSurveyQuestionTypeEnum) => {
|
||||
const parseResult = ZSurveyQuestion.safeParse(card);
|
||||
if (parseResult.success) {
|
||||
const question = parseResult.data;
|
||||
const questionDefaults = getQuestionDefaults(type, product);
|
||||
|
||||
addCard(
|
||||
{
|
||||
...questionDefaults,
|
||||
type,
|
||||
id: createId(),
|
||||
required: true,
|
||||
},
|
||||
cardIdx + 1
|
||||
);
|
||||
|
||||
// scroll to the new question
|
||||
const section = document.getElementById(`${question.id}`);
|
||||
section?.scrollIntoView({ behavior: "smooth", block: "end", inline: "end" });
|
||||
}
|
||||
};
|
||||
|
||||
const addEndingCardBelow = () => {
|
||||
addCard(cardIdx + 1);
|
||||
};
|
||||
|
||||
const onConfirm = () => {
|
||||
changeQuestionType(changeToType);
|
||||
setLogicWarningModal(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex space-x-2">
|
||||
<CopyIcon
|
||||
className="h-4 cursor-pointer text-slate-500 hover:text-slate-600"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
duplicateCard(cardIdx);
|
||||
}}
|
||||
/>
|
||||
<TrashIcon
|
||||
className={cn(
|
||||
"h-4 cursor-pointer text-slate-500",
|
||||
isDeleteDisabled ? "cursor-not-allowed opacity-50" : "hover:text-slate-600"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (isDeleteDisabled) return;
|
||||
deleteCard(cardIdx);
|
||||
}}
|
||||
/>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<EllipsisIcon className="h-4 w-4 text-slate-500 hover:text-slate-600" />
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent className="border border-slate-200">
|
||||
<div className="flex flex-col">
|
||||
{cardType === "question" && (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger className="cursor-pointer text-sm text-slate-600 hover:text-slate-700">
|
||||
Change question type
|
||||
</DropdownMenuSubTrigger>
|
||||
|
||||
<DropdownMenuSubContent className="ml-2 border border-slate-200 text-slate-600 hover:text-slate-700">
|
||||
{Object.entries(QUESTIONS_NAME_MAP).map(([type, name]) => {
|
||||
const parsedResult = ZSurveyQuestion.safeParse(card);
|
||||
if (parsedResult.success) {
|
||||
const question = parsedResult.data;
|
||||
if (type === question.type) return null;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={type}
|
||||
className="min-h-8 cursor-pointer"
|
||||
onClick={() => {
|
||||
setChangeToType(type as TSurveyQuestionTypeEnum);
|
||||
if (question.logic) {
|
||||
setLogicWarningModal(true);
|
||||
return;
|
||||
}
|
||||
|
||||
changeQuestionType(type as TSurveyQuestionTypeEnum);
|
||||
}}>
|
||||
{QUESTIONS_ICON_MAP[type as TSurveyQuestionTypeEnum]}
|
||||
<span className="ml-2">{name}</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
)}
|
||||
{cardType === "ending" && (
|
||||
<DropdownMenuItem
|
||||
className="flex min-h-8 cursor-pointer justify-between text-slate-600 hover:text-slate-700"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
addEndingCardBelow();
|
||||
}}>
|
||||
<span className="text-sm">Add ending below</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{cardType === "question" && (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger className="cursor-pointer text-sm text-slate-600 hover:text-slate-700">
|
||||
Add question below
|
||||
</DropdownMenuSubTrigger>
|
||||
|
||||
<DropdownMenuSubContent className="ml-4 border border-slate-200">
|
||||
{Object.entries(QUESTIONS_NAME_MAP).map(([type, name]) => {
|
||||
if (type === card.type) return null;
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={type}
|
||||
className="min-h-8 cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (cardType === "question") {
|
||||
addQuestionCardBelow(type as TSurveyQuestionTypeEnum);
|
||||
}
|
||||
}}>
|
||||
{QUESTIONS_ICON_MAP[type as TSurveyQuestionTypeEnum]}
|
||||
<span className="ml-2">{name}</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
className={`flex min-h-8 cursor-pointer justify-between text-slate-600 hover:text-slate-700 ${
|
||||
cardIdx === 0 ? "opacity-50" : ""
|
||||
}`}
|
||||
onClick={(e) => {
|
||||
if (cardIdx !== 0) {
|
||||
e.stopPropagation();
|
||||
moveCard(cardIdx, true);
|
||||
}
|
||||
}}
|
||||
disabled={cardIdx === 0}>
|
||||
<span className="text-sm">Move up</span>
|
||||
<ArrowUpIcon className="h-4 w-4" />
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
className={`flex min-h-8 cursor-pointer justify-between text-slate-600 hover:text-slate-700 ${
|
||||
lastCard ? "opacity-50" : ""
|
||||
}`}
|
||||
onClick={(e) => {
|
||||
if (!lastCard) {
|
||||
e.stopPropagation();
|
||||
moveCard(cardIdx, false);
|
||||
}
|
||||
}}
|
||||
disabled={lastCard}>
|
||||
<span className="text-sm text-slate-600 hover:text-slate-700">Move down</span>
|
||||
<ArrowDownIcon className="h-4 w-4" />
|
||||
</DropdownMenuItem>
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<ConfirmationModal
|
||||
open={logicWarningModal}
|
||||
setOpen={setLogicWarningModal}
|
||||
title="Changing will cause logic errors"
|
||||
text="Changing the question type will remove the logic conditions from this question"
|
||||
buttonText="Change anyway"
|
||||
onConfirm={onConfirm}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,124 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
|
||||
import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import { TSurvey, TSurveyEndScreenCard } from "@formbricks/types/surveys/types";
|
||||
import { Input } from "@formbricks/ui/Input";
|
||||
import { Label } from "@formbricks/ui/Label";
|
||||
import { QuestionFormInput } from "@formbricks/ui/QuestionFormInput";
|
||||
import { Switch } from "@formbricks/ui/Switch";
|
||||
|
||||
interface EndScreenFormProps {
|
||||
localSurvey: TSurvey;
|
||||
endingCardIndex: number;
|
||||
isInvalid: boolean;
|
||||
selectedLanguageCode: string;
|
||||
setSelectedLanguageCode: (languageCode: string) => void;
|
||||
attributeClasses: TAttributeClass[];
|
||||
updateSurvey: (input: Partial<TSurveyEndScreenCard>) => void;
|
||||
endingCard: TSurveyEndScreenCard;
|
||||
}
|
||||
|
||||
export const EndScreenForm = ({
|
||||
localSurvey,
|
||||
endingCardIndex,
|
||||
isInvalid,
|
||||
selectedLanguageCode,
|
||||
setSelectedLanguageCode,
|
||||
attributeClasses,
|
||||
updateSurvey,
|
||||
endingCard,
|
||||
}: EndScreenFormProps) => {
|
||||
const [showEndingCardCTA, setshowEndingCardCTA] = useState<boolean>(
|
||||
endingCard.type === "endScreen" &&
|
||||
(!!getLocalizedValue(endingCard.buttonLabel, selectedLanguageCode) || !!endingCard.buttonLink)
|
||||
);
|
||||
return (
|
||||
<form>
|
||||
<QuestionFormInput
|
||||
id="headline"
|
||||
label="Note*"
|
||||
value={endingCard.headline}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={localSurvey.questions.length + endingCardIndex}
|
||||
isInvalid={isInvalid}
|
||||
updateSurvey={updateSurvey}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
attributeClasses={attributeClasses}
|
||||
/>
|
||||
|
||||
<QuestionFormInput
|
||||
id="subheader"
|
||||
value={endingCard.subheader}
|
||||
label={"Description"}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={localSurvey.questions.length + endingCardIndex}
|
||||
isInvalid={isInvalid}
|
||||
updateSurvey={updateSurvey}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
attributeClasses={attributeClasses}
|
||||
/>
|
||||
<div className="mt-4">
|
||||
<div className="flex items-center space-x-1">
|
||||
<Switch
|
||||
id="showButton"
|
||||
checked={showEndingCardCTA}
|
||||
onCheckedChange={() => {
|
||||
if (showEndingCardCTA) {
|
||||
updateSurvey({ buttonLabel: undefined, buttonLink: undefined });
|
||||
} else {
|
||||
updateSurvey({
|
||||
buttonLabel: { default: "Create your own Survey" },
|
||||
buttonLink: "https://formbricks.com/signup",
|
||||
});
|
||||
}
|
||||
setshowEndingCardCTA(!showEndingCardCTA);
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="showButton" className="cursor-pointer">
|
||||
<div className="ml-2">
|
||||
<h3 className="text-sm font-semibold text-slate-700">Show Button</h3>
|
||||
<p className="text-xs font-normal text-slate-500">
|
||||
Send your respondents to a page of your choice.
|
||||
</p>
|
||||
</div>
|
||||
</Label>
|
||||
</div>
|
||||
{showEndingCardCTA && (
|
||||
<div className="border-1 mt-4 space-y-4 rounded-md border bg-slate-100 p-4 pt-2">
|
||||
<div className="space-y-2">
|
||||
<QuestionFormInput
|
||||
id="buttonLabel"
|
||||
label="Button Label"
|
||||
placeholder="Create your own Survey"
|
||||
className="bg-white"
|
||||
value={endingCard.buttonLabel}
|
||||
localSurvey={localSurvey}
|
||||
questionIdx={localSurvey.questions.length + endingCardIndex}
|
||||
isInvalid={isInvalid}
|
||||
updateSurvey={updateSurvey}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
attributeClasses={attributeClasses}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Button Link</Label>
|
||||
<Input
|
||||
id="buttonLink"
|
||||
name="buttonLink"
|
||||
className="bg-white"
|
||||
placeholder="https://formbricks.com/signup"
|
||||
value={endingCard.buttonLink}
|
||||
onChange={(e) => updateSurvey({ buttonLink: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -47,11 +47,7 @@ export const HiddenFieldsCard = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
open ? "scale-100 shadow-lg" : "scale-97 shadow-md",
|
||||
"group z-10 flex flex-row rounded-lg bg-white transition-transform duration-300 ease-in-out"
|
||||
)}>
|
||||
<div className={cn(open ? "shadow-lg" : "shadow-md", "group z-10 flex flex-row rounded-lg bg-white")}>
|
||||
<div
|
||||
className={cn(
|
||||
open ? "bg-slate-50" : "bg-white group-hover:bg-slate-50",
|
||||
@@ -118,11 +114,13 @@ export const HiddenFieldsCard = ({
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const existingQuestionIds = localSurvey.questions.map((question) => question.id);
|
||||
const existingEndingCardIds = localSurvey.endings.map((ending) => ending.id);
|
||||
const existingHiddenFieldIds = localSurvey.hiddenFields.fieldIds ?? [];
|
||||
const validateIdError = validateId(
|
||||
"Hidden field",
|
||||
hiddenField,
|
||||
existingQuestionIds,
|
||||
existingEndingCardIds,
|
||||
existingHiddenFieldIds
|
||||
);
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { AlertCircleIcon, BlocksIcon, CheckIcon, EarthIcon, LinkIcon, MonitorIco
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { getDefaultEndingCard } from "@formbricks/lib/templates";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TProduct } from "@formbricks/types/product";
|
||||
import { TSegment } from "@formbricks/types/segment";
|
||||
@@ -17,10 +18,17 @@ interface HowToSendCardProps {
|
||||
localSurvey: TSurvey;
|
||||
setLocalSurvey: (survey: TSurvey | ((TSurvey: TSurvey) => TSurvey)) => void;
|
||||
environment: TEnvironment;
|
||||
organizationId: string;
|
||||
product: TProduct;
|
||||
}
|
||||
|
||||
export const HowToSendCard = ({ localSurvey, setLocalSurvey, environment, product }: HowToSendCardProps) => {
|
||||
export const HowToSendCard = ({
|
||||
localSurvey,
|
||||
setLocalSurvey,
|
||||
environment,
|
||||
product,
|
||||
organizationId,
|
||||
}: HowToSendCardProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [appSetupCompleted, setAppSetupCompleted] = useState(false);
|
||||
const [websiteSetupCompleted, setWebsiteSetupCompleted] = useState(false);
|
||||
@@ -33,13 +41,14 @@ export const HowToSendCard = ({ localSurvey, setLocalSurvey, environment, produc
|
||||
}, [environment]);
|
||||
|
||||
const setSurveyType = (type: TSurveyType) => {
|
||||
const endingsTemp = localSurvey.endings;
|
||||
if (type === "link" && localSurvey.endings.length === 0) {
|
||||
endingsTemp.push(getDefaultEndingCard(localSurvey.languages));
|
||||
}
|
||||
setLocalSurvey((prevSurvey) => ({
|
||||
...prevSurvey,
|
||||
type,
|
||||
thankYouCard: {
|
||||
...prevSurvey.thankYouCard,
|
||||
enabled: type === "link" ? true : prevSurvey.thankYouCard.enabled,
|
||||
},
|
||||
endings: endingsTemp,
|
||||
}));
|
||||
|
||||
// if the type is "app" and the local survey does not already have a segment, we create a new temporary segment
|
||||
@@ -215,12 +224,18 @@ export const HowToSendCard = ({ localSurvey, setLocalSurvey, environment, produc
|
||||
</RadioGroup>
|
||||
</div>
|
||||
{promotedFeaturesString && (
|
||||
<div className="mt-2 flex items-center space-x-3 rounded-b-lg border border-slate-200 bg-slate-50/50 px-4 py-2">
|
||||
<AlertCircleIcon className="h-5 w-5 text-slate-500" />
|
||||
<div className="text-slate-500">
|
||||
<div className="mt-2 flex items-center space-x-3 rounded-b-lg border border-slate-200 bg-slate-100 px-4 py-2">
|
||||
🤓
|
||||
<div className="ml-2 text-slate-500">
|
||||
<p className="text-xs">
|
||||
You can also use Formbricks to run {promotedFeaturesString} surveys. Create a new product for
|
||||
your {promotedFeaturesString} to use this feature.
|
||||
You can also use Formbricks to run {promotedFeaturesString} surveys.{" "}
|
||||
<Link
|
||||
target="_blank"
|
||||
href={`/organizations/${organizationId}/products/new/channel`}
|
||||
className="font-medium underline decoration-slate-400 underline-offset-2">
|
||||
Create a new product
|
||||
</Link>{" "}
|
||||
for your {promotedFeaturesString} to use this feature.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
SplitIcon,
|
||||
TrashIcon,
|
||||
} from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { useMemo, useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
|
||||
@@ -48,6 +49,39 @@ type LogicConditions = {
|
||||
};
|
||||
};
|
||||
|
||||
const conditions = {
|
||||
openText: ["submitted", "skipped"],
|
||||
multipleChoiceSingle: ["submitted", "skipped", "equals", "notEquals", "includesOne"],
|
||||
multipleChoiceMulti: ["submitted", "skipped", "includesAll", "includesOne", "equals"],
|
||||
nps: [
|
||||
"equals",
|
||||
"notEquals",
|
||||
"lessThan",
|
||||
"lessEqual",
|
||||
"greaterThan",
|
||||
"greaterEqual",
|
||||
"submitted",
|
||||
"skipped",
|
||||
],
|
||||
rating: [
|
||||
"equals",
|
||||
"notEquals",
|
||||
"lessThan",
|
||||
"lessEqual",
|
||||
"greaterThan",
|
||||
"greaterEqual",
|
||||
"submitted",
|
||||
"skipped",
|
||||
],
|
||||
cta: ["clicked", "skipped"],
|
||||
consent: ["skipped", "accepted"],
|
||||
pictureSelection: ["submitted", "skipped", "includesAll", "includesOne", "equals"],
|
||||
fileUpload: ["uploaded", "notUploaded"],
|
||||
cal: ["skipped", "booked"],
|
||||
matrix: ["isCompletelySubmitted", "isPartiallySubmitted", "skipped"],
|
||||
address: ["submitted", "skipped"],
|
||||
};
|
||||
|
||||
export const LogicEditor = ({
|
||||
localSurvey,
|
||||
question,
|
||||
@@ -56,54 +90,27 @@ export const LogicEditor = ({
|
||||
attributeClasses,
|
||||
}: LogicEditorProps) => {
|
||||
const [searchValue, setSearchValue] = useState<string>("");
|
||||
localSurvey = useMemo(() => {
|
||||
const showDropdownSearch = question.type !== "pictureSelection";
|
||||
const transformedSurvey = useMemo(() => {
|
||||
return replaceHeadlineRecall(localSurvey, "default", attributeClasses);
|
||||
}, [localSurvey, attributeClasses]);
|
||||
|
||||
const questionValues = useMemo(() => {
|
||||
const questionValues: string[] = useMemo(() => {
|
||||
if ("choices" in question) {
|
||||
return question.choices.map((choice) => getLocalizedValue(choice.label, "default"));
|
||||
if (question.type === "pictureSelection") {
|
||||
return question.choices.map((choice) => choice.id);
|
||||
} else {
|
||||
return question.choices.map((choice) => getLocalizedValue(choice.label, "default"));
|
||||
}
|
||||
} else if ("range" in question) {
|
||||
return Array.from({ length: question.range ? question.range : 0 }, (_, i) => (i + 1).toString());
|
||||
} else if (question.type === TSurveyQuestionTypeEnum.NPS) {
|
||||
return Array.from({ length: 11 }, (_, i) => (i + 0).toString());
|
||||
}
|
||||
|
||||
return [];
|
||||
}, [question]);
|
||||
|
||||
const conditions = {
|
||||
openText: ["submitted", "skipped"],
|
||||
multipleChoiceSingle: ["submitted", "skipped", "equals", "notEquals", "includesOne"],
|
||||
multipleChoiceMulti: ["submitted", "skipped", "includesAll", "includesOne", "equals"],
|
||||
nps: [
|
||||
"equals",
|
||||
"notEquals",
|
||||
"lessThan",
|
||||
"lessEqual",
|
||||
"greaterThan",
|
||||
"greaterEqual",
|
||||
"submitted",
|
||||
"skipped",
|
||||
],
|
||||
rating: [
|
||||
"equals",
|
||||
"notEquals",
|
||||
"lessThan",
|
||||
"lessEqual",
|
||||
"greaterThan",
|
||||
"greaterEqual",
|
||||
"submitted",
|
||||
"skipped",
|
||||
],
|
||||
cta: ["clicked", "skipped"],
|
||||
consent: ["skipped", "accepted"],
|
||||
pictureSelection: ["submitted", "skipped"],
|
||||
fileUpload: ["uploaded", "notUploaded"],
|
||||
cal: ["skipped", "booked"],
|
||||
matrix: ["isCompletelySubmitted", "isPartiallySubmitted", "skipped"],
|
||||
address: ["submitted", "skipped"],
|
||||
};
|
||||
|
||||
const logicConditions: LogicConditions = {
|
||||
submitted: {
|
||||
label: "is submitted",
|
||||
@@ -270,13 +277,34 @@ export const LogicEditor = ({
|
||||
return <></>;
|
||||
}
|
||||
|
||||
const getLogicDisplayValue = (value: string | string[]) => {
|
||||
if (Array.isArray(value)) {
|
||||
const getLogicDisplayValue = (value: string | string[]): string => {
|
||||
if (question.type === "pictureSelection") {
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map((val) => {
|
||||
const choiceIndex = question.choices.findIndex((choice) => choice.id === val);
|
||||
return `Picture ${choiceIndex + 1}`;
|
||||
})
|
||||
.join(", ");
|
||||
} else {
|
||||
const choiceIndex = question.choices.findIndex((choice) => choice.id === value);
|
||||
return `Picture ${choiceIndex + 1}`;
|
||||
}
|
||||
} else if (Array.isArray(value)) {
|
||||
return value.join(", ");
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const getOptionPreview = (value: string) => {
|
||||
if (question.type === "pictureSelection") {
|
||||
const choice = question.choices.find((choice) => choice.id === value);
|
||||
if (choice) {
|
||||
return <Image src={choice.imageUrl} alt={"Picture"} width={20} height={12} className="rounded-xs" />;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-3">
|
||||
<Label>Logic Jumps</Label>
|
||||
@@ -330,14 +358,16 @@ export const LogicEditor = ({
|
||||
className="w-40 bg-slate-50 text-slate-700"
|
||||
align="start"
|
||||
side="bottom">
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder="Search options"
|
||||
className="mb-1 w-full bg-white"
|
||||
onChange={(e) => setSearchValue(e.target.value)}
|
||||
value={searchValue}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
/>
|
||||
{showDropdownSearch && (
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder="Search options"
|
||||
className="mb-1 w-full bg-white"
|
||||
onChange={(e) => setSearchValue(e.target.value)}
|
||||
value={searchValue}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
/>
|
||||
)}
|
||||
<div className="max-h-72 overflow-y-auto overflow-x-hidden">
|
||||
{logicConditions[logic.condition].values
|
||||
?.filter((value) => value.includes(searchValue))
|
||||
@@ -356,7 +386,10 @@ export const LogicEditor = ({
|
||||
? updateLogic(logicIdx, { value })
|
||||
: updateMultiSelectLogic(logicIdx, e, value)
|
||||
}>
|
||||
{value}
|
||||
<div className="flex space-x-2">
|
||||
{question.type === "pictureSelection" && getOptionPreview(value)}
|
||||
<p>{getLogicDisplayValue(value)}</p>
|
||||
</div>
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</div>
|
||||
@@ -373,7 +406,7 @@ export const LogicEditor = ({
|
||||
<SelectValue placeholder="Select question" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{localSurvey.questions.map(
|
||||
{transformedSurvey.questions.map(
|
||||
(question, idx) =>
|
||||
idx !== questionIdx && (
|
||||
<SelectItem
|
||||
@@ -390,7 +423,15 @@ export const LogicEditor = ({
|
||||
</SelectItem>
|
||||
)
|
||||
)}
|
||||
<SelectItem value="end">End of survey</SelectItem>
|
||||
{localSurvey.endings.map((ending) => {
|
||||
return (
|
||||
<SelectItem value={ending.id}>
|
||||
{ending.type === "endScreen"
|
||||
? getLocalizedValue(ending.headline, "default")
|
||||
: ending.label}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div>
|
||||
|
||||
@@ -10,8 +10,8 @@ import {
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { Label } from "@formbricks/ui/Label";
|
||||
import { OptionsSwitch } from "@formbricks/ui/OptionsSwitch";
|
||||
import { QuestionFormInput } from "@formbricks/ui/QuestionFormInput";
|
||||
import { OptionsSwitcher } from "@formbricks/ui/QuestionTypeSelector";
|
||||
|
||||
const questionTypes = [
|
||||
{ value: "text", label: "Text", icon: <MessageSquareTextIcon className="h-4 w-4" /> },
|
||||
@@ -127,10 +127,10 @@ export const OpenQuestionForm = ({
|
||||
<div className="mt-3">
|
||||
<Label htmlFor="questionType">Input Type</Label>
|
||||
<div className="mt-2 flex items-center">
|
||||
<OptionsSwitcher
|
||||
<OptionsSwitch
|
||||
options={questionTypes}
|
||||
currentOption={question.inputType}
|
||||
handleTypeChange={handleInputChange} // Use the merged function
|
||||
handleOptionChange={handleInputChange} // Use the merged function
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -35,6 +35,48 @@ export const PictureSelectionForm = ({
|
||||
const environmentId = localSurvey.environmentId;
|
||||
const surveyLanguageCodes = extractLanguageCodes(localSurvey.languages);
|
||||
|
||||
const handleChoiceDeletion = (choiceValue: string) => {
|
||||
// Filter out the deleted choice from the choices array
|
||||
const newChoices = question.choices?.filter((choice) => choice.id !== choiceValue) || [];
|
||||
|
||||
// Update the logic, removing the deleted choice value
|
||||
const newLogic =
|
||||
question.logic?.map((logic) => {
|
||||
let updatedValue = logic.value;
|
||||
|
||||
if (Array.isArray(logic.value)) {
|
||||
updatedValue = logic.value.filter((value) => value !== choiceValue);
|
||||
} else if (logic.value === choiceValue) {
|
||||
updatedValue = undefined;
|
||||
}
|
||||
|
||||
return { ...logic, value: updatedValue };
|
||||
}) || [];
|
||||
|
||||
// Update the question with new choices and logic
|
||||
updateQuestion(questionIdx, { choices: newChoices, logic: newLogic });
|
||||
};
|
||||
|
||||
const handleFileInputChanges = (urls: string[]) => {
|
||||
// Handle choice deletion
|
||||
if (urls.length < question.choices.length) {
|
||||
const deletedChoice = question.choices.find((choice) => !urls.includes(choice.imageUrl));
|
||||
if (deletedChoice) {
|
||||
handleChoiceDeletion(deletedChoice.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle choice addition
|
||||
const updatedChoices = urls.map((url) => {
|
||||
const existingChoice = question.choices.find((choice) => choice.imageUrl === url);
|
||||
return existingChoice ? { ...existingChoice } : { imageUrl: url, id: createId() };
|
||||
});
|
||||
|
||||
updateQuestion(questionIdx, {
|
||||
choices: updatedChoices,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form>
|
||||
<QuestionFormInput
|
||||
@@ -99,11 +141,7 @@ export const PictureSelectionForm = ({
|
||||
id="choices-file-input"
|
||||
allowedFileExtensions={["png", "jpeg", "jpg"]}
|
||||
environmentId={environmentId}
|
||||
onFileUpload={(urls: string[]) => {
|
||||
updateQuestion(questionIdx, {
|
||||
choices: urls.map((url) => ({ imageUrl: url, id: createId() })),
|
||||
});
|
||||
}}
|
||||
onFileUpload={handleFileInputChanges}
|
||||
fileUrl={question?.choices?.map((choice) => choice.imageUrl)}
|
||||
multiple={true}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { formatTextWithSlashes } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/lib/util";
|
||||
import { QUESTIONS_ICON_MAP, getTSurveyQuestionTypeEnumName } from "@/app/lib/questions";
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
@@ -25,13 +26,13 @@ import { CTAQuestionForm } from "./CTAQuestionForm";
|
||||
import { CalQuestionForm } from "./CalQuestionForm";
|
||||
import { ConsentQuestionForm } from "./ConsentQuestionForm";
|
||||
import { DateQuestionForm } from "./DateQuestionForm";
|
||||
import { EditorCardMenu } from "./EditorCardMenu";
|
||||
import { FileUploadQuestionForm } from "./FileUploadQuestionForm";
|
||||
import { MatrixQuestionForm } from "./MatrixQuestionForm";
|
||||
import { MultipleChoiceQuestionForm } from "./MultipleChoiceQuestionForm";
|
||||
import { NPSQuestionForm } from "./NPSQuestionForm";
|
||||
import { OpenQuestionForm } from "./OpenQuestionForm";
|
||||
import { PictureSelectionForm } from "./PictureSelectionForm";
|
||||
import { QuestionMenu } from "./QuestionMenu";
|
||||
import { RatingQuestionForm } from "./RatingQuestionForm";
|
||||
|
||||
interface QuestionCardProps {
|
||||
@@ -80,25 +81,6 @@ export const QuestionCard = ({
|
||||
const open = activeQuestionId === question.id;
|
||||
const [openAdvanced, setOpenAdvanced] = useState(question.logic && question.logic.length > 0);
|
||||
|
||||
// formats the text to highlight specific parts of the text with slashes
|
||||
const formatTextWithSlashes = (text) => {
|
||||
const regex = /\/(.*?)\\/g;
|
||||
const parts = text.split(regex);
|
||||
|
||||
return parts.map((part, index) => {
|
||||
// Check if the part was inside slashes
|
||||
if (index % 2 !== 0) {
|
||||
return (
|
||||
<span key={index} className="mx-1 rounded-md bg-slate-100 p-1 px-2 text-xs">
|
||||
{part}
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
return part;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const updateEmptyNextButtonLabels = (labelValue: TI18nString) => {
|
||||
localSurvey.questions.forEach((q, index) => {
|
||||
if (index === localSurvey.questions.length - 1) return;
|
||||
@@ -140,8 +122,8 @@ export const QuestionCard = ({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
open ? "scale-100 shadow-lg" : "scale-97 shadow-md",
|
||||
"flex w-full flex-row rounded-lg bg-white transition-all duration-300 ease-in-out"
|
||||
open ? "shadow-lg" : "shadow-md",
|
||||
"flex w-full flex-row rounded-lg bg-white duration-300"
|
||||
)}
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
@@ -151,11 +133,11 @@ export const QuestionCard = ({
|
||||
{...attributes}
|
||||
className={cn(
|
||||
open ? "bg-slate-700" : "bg-slate-400",
|
||||
"top-0 w-[5%] rounded-l-lg p-2 text-center text-sm text-white hover:cursor-grab hover:bg-slate-600",
|
||||
"top-0 w-10 rounded-l-lg p-2 text-center text-sm text-white hover:cursor-grab hover:bg-slate-600",
|
||||
isInvalid && "bg-red-400 hover:bg-red-600",
|
||||
"flex flex-col items-center justify-between"
|
||||
)}>
|
||||
<span>{questionIdx + 1}</span>
|
||||
<div className="mt-3 flex w-full justify-center">{QUESTIONS_ICON_MAP[question.type]}</div>
|
||||
|
||||
<button className="opacity-0 hover:cursor-move group-hover:opacity-100">
|
||||
<GripIcon className="h-4 w-4" />
|
||||
@@ -173,12 +155,15 @@ export const QuestionCard = ({
|
||||
className="w-[95%] flex-1 rounded-r-lg border border-slate-200">
|
||||
<Collapsible.CollapsibleTrigger
|
||||
asChild
|
||||
className={cn(open ? "" : " ", "flex cursor-pointer justify-between gap-4 p-4 hover:bg-slate-50")}>
|
||||
className={cn(
|
||||
open ? "" : " ",
|
||||
"flex cursor-pointer justify-between gap-4 rounded-r-lg p-4 hover:bg-slate-50"
|
||||
)}>
|
||||
<div>
|
||||
<div className="flex grow">
|
||||
<div className="-ml-0.5 mr-3 h-6 min-w-[1.5rem] text-slate-400">
|
||||
{/* <div className="-ml-0.5 mr-3 h-6 min-w-[1.5rem] text-slate-400">
|
||||
{QUESTIONS_ICON_MAP[question.type]}
|
||||
</div>
|
||||
</div> */}
|
||||
<div className="grow" dir="auto">
|
||||
<p className="text-sm font-semibold">
|
||||
{recallToHeadline(
|
||||
@@ -199,23 +184,27 @@ export const QuestionCard = ({
|
||||
)
|
||||
: getTSurveyQuestionTypeEnumName(question.type)}
|
||||
</p>
|
||||
{!open && question?.required && (
|
||||
<p className="mt-1 truncate text-xs text-slate-500">{question?.required && "Required"}</p>
|
||||
{!open && (
|
||||
<p className="mt-1 truncate text-xs text-slate-500">
|
||||
{question?.required ? "Required" : "Optional"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<QuestionMenu
|
||||
questionIdx={questionIdx}
|
||||
lastQuestion={lastQuestion}
|
||||
duplicateQuestion={duplicateQuestion}
|
||||
deleteQuestion={deleteQuestion}
|
||||
moveQuestion={moveQuestion}
|
||||
question={question}
|
||||
<EditorCardMenu
|
||||
survey={localSurvey}
|
||||
cardIdx={questionIdx}
|
||||
lastCard={lastQuestion}
|
||||
duplicateCard={duplicateQuestion}
|
||||
deleteCard={deleteQuestion}
|
||||
moveCard={moveQuestion}
|
||||
card={question}
|
||||
product={product}
|
||||
updateQuestion={updateQuestion}
|
||||
addQuestion={addQuestion}
|
||||
updateCard={updateQuestion}
|
||||
addCard={addQuestion}
|
||||
cardType="question"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,231 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { QUESTIONS_ICON_MAP, QUESTIONS_NAME_MAP, getQuestionDefaults } from "@/app/lib/questions";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { ArrowDownIcon, ArrowUpIcon, CopyIcon, EllipsisIcon, TrashIcon } from "lucide-react";
|
||||
import React, { useState } from "react";
|
||||
import { TProduct } from "@formbricks/types/product";
|
||||
import { TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { ConfirmationModal } from "@formbricks/ui/ConfirmationModal";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "@formbricks/ui/DropdownMenu";
|
||||
|
||||
interface QuestionMenuProps {
|
||||
questionIdx: number;
|
||||
lastQuestion: boolean;
|
||||
duplicateQuestion: (questionIdx: number) => void;
|
||||
deleteQuestion: (questionIdx: number) => void;
|
||||
moveQuestion: (questionIdx: number, up: boolean) => void;
|
||||
question: TSurveyQuestion;
|
||||
product: TProduct;
|
||||
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
|
||||
addQuestion: (question: any, index?: number) => void;
|
||||
}
|
||||
|
||||
export const QuestionMenu = ({
|
||||
questionIdx,
|
||||
lastQuestion,
|
||||
duplicateQuestion,
|
||||
deleteQuestion,
|
||||
moveQuestion,
|
||||
product,
|
||||
question,
|
||||
updateQuestion,
|
||||
addQuestion,
|
||||
}: QuestionMenuProps) => {
|
||||
const [logicWarningModal, setLogicWarningModal] = useState(false);
|
||||
const [changeToType, setChangeToType] = useState(question.type);
|
||||
|
||||
const changeQuestionType = (type: TSurveyQuestionTypeEnum) => {
|
||||
const { headline, required, subheader, imageUrl, videoUrl, buttonLabel, backButtonLabel } = question;
|
||||
|
||||
const questionDefaults = getQuestionDefaults(type, product);
|
||||
|
||||
// if going from single select to multi select or vice versa, we need to keep the choices as well
|
||||
|
||||
if (
|
||||
(type === TSurveyQuestionTypeEnum.MultipleChoiceSingle &&
|
||||
question.type === TSurveyQuestionTypeEnum.MultipleChoiceMulti) ||
|
||||
(type === TSurveyQuestionTypeEnum.MultipleChoiceMulti &&
|
||||
question.type === TSurveyQuestionTypeEnum.MultipleChoiceSingle)
|
||||
) {
|
||||
updateQuestion(questionIdx, {
|
||||
choices: question.choices,
|
||||
type,
|
||||
logic: undefined,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
updateQuestion(questionIdx, {
|
||||
...questionDefaults,
|
||||
type,
|
||||
headline,
|
||||
subheader,
|
||||
required,
|
||||
imageUrl,
|
||||
videoUrl,
|
||||
buttonLabel,
|
||||
backButtonLabel,
|
||||
logic: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const addQuestionBelow = (type: TSurveyQuestionTypeEnum) => {
|
||||
const questionDefaults = getQuestionDefaults(type, product);
|
||||
|
||||
addQuestion(
|
||||
{
|
||||
...questionDefaults,
|
||||
type,
|
||||
id: createId(),
|
||||
required: true,
|
||||
},
|
||||
questionIdx + 1
|
||||
);
|
||||
|
||||
// scroll to the new question
|
||||
const section = document.getElementById(`${question.id}`);
|
||||
section?.scrollIntoView({ behavior: "smooth", block: "end", inline: "end" });
|
||||
};
|
||||
|
||||
const onConfirm = () => {
|
||||
changeQuestionType(changeToType);
|
||||
setLogicWarningModal(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex space-x-2">
|
||||
<CopyIcon
|
||||
className="h-4 cursor-pointer text-slate-500 hover:text-slate-600"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
duplicateQuestion(questionIdx);
|
||||
}}
|
||||
/>
|
||||
<TrashIcon
|
||||
className="h-4 cursor-pointer text-slate-500 hover:text-slate-600"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteQuestion(questionIdx);
|
||||
}}
|
||||
/>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<EllipsisIcon className="h-4 w-4 text-slate-500 hover:text-slate-600" />
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent>
|
||||
<div className="flex flex-col">
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<div className="cursor-pointer text-slate-500 hover:text-slate-600">
|
||||
<span className="text-xs text-slate-500">Change question type</span>
|
||||
</div>
|
||||
</DropdownMenuSubTrigger>
|
||||
|
||||
<DropdownMenuSubContent className="ml-4 border border-slate-200">
|
||||
{Object.entries(QUESTIONS_NAME_MAP).map(([type, name]) => {
|
||||
if (type === question.type) return null;
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={type}
|
||||
className="min-h-8 cursor-pointer text-slate-500"
|
||||
onClick={() => {
|
||||
setChangeToType(type as TSurveyQuestionTypeEnum);
|
||||
if (question.logic) {
|
||||
setLogicWarningModal(true);
|
||||
return;
|
||||
}
|
||||
|
||||
changeQuestionType(type as TSurveyQuestionTypeEnum);
|
||||
}}>
|
||||
{QUESTIONS_ICON_MAP[type as TSurveyQuestionTypeEnum]}
|
||||
<span className="ml-2">{name}</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<div className="cursor-pointer text-slate-500 hover:text-slate-600">
|
||||
<span className="text-xs text-slate-500">Add question below</span>
|
||||
</div>
|
||||
</DropdownMenuSubTrigger>
|
||||
|
||||
<DropdownMenuSubContent className="ml-4 border border-slate-200">
|
||||
{Object.entries(QUESTIONS_NAME_MAP).map(([type, name]) => {
|
||||
if (type === question.type) return null;
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={type}
|
||||
className="min-h-8 cursor-pointer text-slate-500"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
addQuestionBelow(type as TSurveyQuestionTypeEnum);
|
||||
}}>
|
||||
{QUESTIONS_ICON_MAP[type as TSurveyQuestionTypeEnum]}
|
||||
<span className="ml-2">{name}</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
<DropdownMenuItem
|
||||
className={`flex min-h-8 cursor-pointer justify-between text-slate-500 hover:text-slate-600 ${
|
||||
questionIdx === 0 ? "opacity-50" : ""
|
||||
}`}
|
||||
onClick={(e) => {
|
||||
if (questionIdx !== 0) {
|
||||
e.stopPropagation();
|
||||
moveQuestion(questionIdx, true);
|
||||
}
|
||||
}}
|
||||
disabled={questionIdx === 0}>
|
||||
<span className="text-xs text-slate-500">Move up</span>
|
||||
<ArrowUpIcon className="h-4 w-4" />
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
className={`flex min-h-8 cursor-pointer justify-between text-slate-500 hover:text-slate-600 ${
|
||||
lastQuestion ? "opacity-50" : ""
|
||||
}`}
|
||||
onClick={(e) => {
|
||||
if (!lastQuestion) {
|
||||
e.stopPropagation();
|
||||
moveQuestion(questionIdx, false);
|
||||
}
|
||||
}}
|
||||
disabled={lastQuestion}>
|
||||
<span className="text-xs text-slate-500">Move down</span>
|
||||
<ArrowDownIcon className="h-4 w-4" />
|
||||
</DropdownMenuItem>
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<ConfirmationModal
|
||||
open={logicWarningModal}
|
||||
setOpen={setLogicWarningModal}
|
||||
title="Changing will cause logic errors"
|
||||
text="Changing the question type will remove the logic conditions from this question"
|
||||
buttonText="Change anyway"
|
||||
onConfirm={onConfirm}
|
||||
buttonVariant="darkCTA"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { AddEndingCardButton } from "@/app/(app)/(survey-editor)/environments/[environmentId]/surveys/[surveyId]/edit/components/AddEndingCardButton";
|
||||
import {
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
@@ -8,20 +9,28 @@ import {
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from "@dnd-kit/core";
|
||||
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import React, { SetStateAction, useEffect, useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { MultiLanguageCard } from "@formbricks/ee/multi-language/components/multi-language-card";
|
||||
import { extractLanguageCodes, getLocalizedValue, translateQuestion } from "@formbricks/lib/i18n/utils";
|
||||
import { structuredClone } from "@formbricks/lib/pollyfills/structuredClone";
|
||||
import { getDefaultEndingCard } from "@formbricks/lib/templates";
|
||||
import { checkForEmptyFallBackValue, extractRecallInfo } from "@formbricks/lib/utils/recall";
|
||||
import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import { TOrganizationBillingPlan } from "@formbricks/types/organizations";
|
||||
import { TProduct } from "@formbricks/types/product";
|
||||
import { TSurvey, TSurveyQuestion } from "@formbricks/types/surveys/types";
|
||||
import { findQuestionsWithCyclicLogic } from "@formbricks/types/surveys/validation";
|
||||
import { isCardValid, validateQuestion, validateSurveyQuestionsInBatch } from "../lib/validation";
|
||||
import {
|
||||
isEndingCardValid,
|
||||
isWelcomeCardValid,
|
||||
validateQuestion,
|
||||
validateSurveyQuestionsInBatch,
|
||||
} from "../lib/validation";
|
||||
import { AddQuestionButton } from "./AddQuestionButton";
|
||||
import { EditThankYouCard } from "./EditThankYouCard";
|
||||
import { EditEndingCard } from "./EditEndingCard";
|
||||
import { EditWelcomeCard } from "./EditWelcomeCard";
|
||||
import { HiddenFieldsCard } from "./HiddenFieldsCard";
|
||||
import { QuestionsDroppable } from "./QuestionsDroppable";
|
||||
@@ -39,6 +48,7 @@ interface QuestionsViewProps {
|
||||
isMultiLanguageAllowed?: boolean;
|
||||
isFormbricksCloud: boolean;
|
||||
attributeClasses: TAttributeClass[];
|
||||
plan: TOrganizationBillingPlan;
|
||||
}
|
||||
|
||||
export const QuestionsView = ({
|
||||
@@ -54,6 +64,7 @@ export const QuestionsView = ({
|
||||
isMultiLanguageAllowed,
|
||||
isFormbricksCloud,
|
||||
attributeClasses,
|
||||
plan,
|
||||
}: QuestionsViewProps) => {
|
||||
const internalQuestionIdMap = useMemo(() => {
|
||||
return localSurvey.questions.reduce((acc, question) => {
|
||||
@@ -82,6 +93,36 @@ export const QuestionsView = ({
|
||||
return survey;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!invalidQuestions) return;
|
||||
let updatedInvalidQuestions: string[] = invalidQuestions;
|
||||
|
||||
// Check welcome card
|
||||
if (localSurvey.welcomeCard.enabled && !isWelcomeCardValid(localSurvey.welcomeCard, surveyLanguages)) {
|
||||
if (!updatedInvalidQuestions.includes("start")) {
|
||||
updatedInvalidQuestions.push("start");
|
||||
}
|
||||
} else {
|
||||
updatedInvalidQuestions = updatedInvalidQuestions.filter((questionId) => questionId !== "start");
|
||||
}
|
||||
|
||||
// Check thank you card
|
||||
localSurvey.endings.forEach((ending) => {
|
||||
if (!isEndingCardValid(ending, surveyLanguages)) {
|
||||
if (!updatedInvalidQuestions.includes(ending.id)) {
|
||||
updatedInvalidQuestions.push(ending.id);
|
||||
}
|
||||
} else {
|
||||
updatedInvalidQuestions = updatedInvalidQuestions.filter((questionId) => questionId !== ending.id);
|
||||
}
|
||||
});
|
||||
|
||||
if (JSON.stringify(updatedInvalidQuestions) !== JSON.stringify(invalidQuestions)) {
|
||||
setInvalidQuestions(updatedInvalidQuestions);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [localSurvey.languages, localSurvey.endings, localSurvey.welcomeCard]);
|
||||
|
||||
// function to validate individual questions
|
||||
const validateSurveyQuestion = (question: TSurveyQuestion) => {
|
||||
// prevent this function to execute further if user hasnt still tried to save the survey
|
||||
@@ -111,7 +152,7 @@ export const QuestionsView = ({
|
||||
const updateQuestion = (questionIdx: number, updatedAttributes: any) => {
|
||||
let updatedSurvey = { ...localSurvey };
|
||||
if ("id" in updatedAttributes) {
|
||||
// if the survey whose id is to be changed is linked to logic of any other survey then changing it
|
||||
// if the survey question whose id is to be changed is linked to logic of any other survey then changing it
|
||||
const initialQuestionId = updatedSurvey.questions[questionIdx].id;
|
||||
updatedSurvey = handleQuestionLogicChange(updatedSurvey, initialQuestionId, updatedAttributes.id);
|
||||
if (invalidQuestions?.includes(initialQuestionId)) {
|
||||
@@ -181,14 +222,15 @@ export const QuestionsView = ({
|
||||
}
|
||||
});
|
||||
updatedSurvey.questions.splice(questionIdx, 1);
|
||||
updatedSurvey = handleQuestionLogicChange(updatedSurvey, questionId, "end");
|
||||
updatedSurvey = handleQuestionLogicChange(updatedSurvey, questionId, "");
|
||||
const firstEndingCard = localSurvey.endings[0];
|
||||
setLocalSurvey(updatedSurvey);
|
||||
delete internalQuestionIdMap[questionId];
|
||||
if (questionId === activeQuestionIdTemp) {
|
||||
if (questionIdx <= localSurvey.questions.length && localSurvey.questions.length > 0) {
|
||||
setActiveQuestionId(localSurvey.questions[questionIdx % localSurvey.questions.length].id);
|
||||
} else if (localSurvey.thankYouCard.enabled) {
|
||||
setActiveQuestionId("end");
|
||||
} else if (firstEndingCard) {
|
||||
setActiveQuestionId(firstEndingCard.id);
|
||||
}
|
||||
}
|
||||
toast.success("Question deleted.");
|
||||
@@ -235,6 +277,15 @@ export const QuestionsView = ({
|
||||
internalQuestionIdMap[question.id] = createId();
|
||||
};
|
||||
|
||||
const addEndingCard = (index: number) => {
|
||||
const updatedSurvey = structuredClone(localSurvey);
|
||||
const newEndingCard = getDefaultEndingCard(localSurvey.languages);
|
||||
|
||||
updatedSurvey.endings.splice(index, 0, newEndingCard);
|
||||
|
||||
setLocalSurvey(updatedSurvey);
|
||||
};
|
||||
|
||||
const moveQuestion = (questionIndex: number, up: boolean) => {
|
||||
const newQuestions = Array.from(localSurvey.questions);
|
||||
const [reorderedQuestion] = newQuestions.splice(questionIndex, 1);
|
||||
@@ -244,29 +295,6 @@ export const QuestionsView = ({
|
||||
setLocalSurvey(updatedSurvey);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (invalidQuestions === null) return;
|
||||
|
||||
const updateInvalidQuestions = (card, cardId, currentInvalidQuestions) => {
|
||||
if (card.enabled && !isCardValid(card, cardId, surveyLanguages)) {
|
||||
return currentInvalidQuestions.includes(cardId)
|
||||
? currentInvalidQuestions
|
||||
: [...currentInvalidQuestions, cardId];
|
||||
}
|
||||
return currentInvalidQuestions.filter((id) => id !== cardId);
|
||||
};
|
||||
|
||||
const updatedQuestionsStart = updateInvalidQuestions(localSurvey.welcomeCard, "start", invalidQuestions);
|
||||
const updatedQuestionsEnd = updateInvalidQuestions(
|
||||
localSurvey.thankYouCard,
|
||||
"end",
|
||||
updatedQuestionsStart
|
||||
);
|
||||
|
||||
setInvalidQuestions(updatedQuestionsEnd);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [localSurvey.welcomeCard, localSurvey.thankYouCard]);
|
||||
|
||||
//useEffect to validate survey when changes are made to languages
|
||||
useEffect(() => {
|
||||
if (!invalidQuestions) return;
|
||||
@@ -281,29 +309,11 @@ export const QuestionsView = ({
|
||||
);
|
||||
});
|
||||
|
||||
// Check welcome card
|
||||
if (localSurvey.welcomeCard.enabled && !isCardValid(localSurvey.welcomeCard, "start", surveyLanguages)) {
|
||||
if (!updatedInvalidQuestions.includes("start")) {
|
||||
updatedInvalidQuestions.push("start");
|
||||
}
|
||||
} else {
|
||||
updatedInvalidQuestions = updatedInvalidQuestions.filter((questionId) => questionId !== "start");
|
||||
}
|
||||
|
||||
// Check thank you card
|
||||
if (localSurvey.thankYouCard.enabled && !isCardValid(localSurvey.thankYouCard, "end", surveyLanguages)) {
|
||||
if (!updatedInvalidQuestions.includes("end")) {
|
||||
updatedInvalidQuestions.push("end");
|
||||
}
|
||||
} else {
|
||||
updatedInvalidQuestions = updatedInvalidQuestions.filter((questionId) => questionId !== "end");
|
||||
}
|
||||
|
||||
if (JSON.stringify(updatedInvalidQuestions) !== JSON.stringify(invalidQuestions)) {
|
||||
setInvalidQuestions(updatedInvalidQuestions);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [localSurvey.languages, localSurvey.questions]);
|
||||
}, [localSurvey.languages, localSurvey.questions, localSurvey.endings, localSurvey.welcomeCard]);
|
||||
|
||||
useEffect(() => {
|
||||
const questionWithEmptyFallback = checkForEmptyFallBackValue(localSurvey, selectedLanguageCode);
|
||||
@@ -324,7 +334,7 @@ export const QuestionsView = ({
|
||||
})
|
||||
);
|
||||
|
||||
const onDragEnd = (event: DragEndEvent) => {
|
||||
const onQuestionCardDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
const newQuestions = Array.from(localSurvey.questions);
|
||||
@@ -336,6 +346,17 @@ export const QuestionsView = ({
|
||||
setLocalSurvey(updatedSurvey);
|
||||
};
|
||||
|
||||
const onEndingCardDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
const newEndings = Array.from(localSurvey.endings);
|
||||
const sourceIndex = newEndings.findIndex((ending) => ending.id === active.id);
|
||||
const destinationIndex = newEndings.findIndex((ending) => ending.id === over?.id);
|
||||
const [reorderedEndings] = newEndings.splice(sourceIndex, 1);
|
||||
newEndings.splice(destinationIndex, 0, reorderedEndings);
|
||||
const updatedSurvey = { ...localSurvey, endings: newEndings };
|
||||
setLocalSurvey(updatedSurvey);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-16 w-full px-5 py-4">
|
||||
<div className="mb-5 flex w-full flex-col gap-5">
|
||||
@@ -351,7 +372,7 @@ export const QuestionsView = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DndContext sensors={sensors} onDragEnd={onDragEnd} collisionDetection={closestCorners}>
|
||||
<DndContext sensors={sensors} onDragEnd={onQuestionCardDragEnd} collisionDetection={closestCorners}>
|
||||
<QuestionsDroppable
|
||||
localSurvey={localSurvey}
|
||||
product={product}
|
||||
@@ -373,16 +394,37 @@ export const QuestionsView = ({
|
||||
|
||||
<AddQuestionButton addQuestion={addQuestion} product={product} />
|
||||
<div className="mt-5 flex flex-col gap-5">
|
||||
<EditThankYouCard
|
||||
<hr className="border-t border-dashed" />
|
||||
<DndContext sensors={sensors} onDragEnd={onEndingCardDragEnd} collisionDetection={closestCorners}>
|
||||
<SortableContext items={localSurvey.endings} strategy={verticalListSortingStrategy}>
|
||||
{localSurvey.endings.map((ending, index) => {
|
||||
return (
|
||||
<EditEndingCard
|
||||
key={ending.id}
|
||||
localSurvey={localSurvey}
|
||||
endingCardIndex={index}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
setActiveQuestionId={setActiveQuestionId}
|
||||
activeQuestionId={activeQuestionId}
|
||||
isInvalid={invalidQuestions ? invalidQuestions.includes(ending.id) : false}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
attributeClasses={attributeClasses}
|
||||
plan={plan}
|
||||
addEndingCard={addEndingCard}
|
||||
isFormbricksCloud={isFormbricksCloud}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
|
||||
<AddEndingCardButton
|
||||
localSurvey={localSurvey}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
setActiveQuestionId={setActiveQuestionId}
|
||||
activeQuestionId={activeQuestionId}
|
||||
isInvalid={invalidQuestions ? invalidQuestions.includes("end") : false}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
attributeClasses={attributeClasses}
|
||||
addEndingCard={addEndingCard}
|
||||
/>
|
||||
<hr />
|
||||
|
||||
<HiddenFieldsCard
|
||||
localSurvey={localSurvey}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import React from "react";
|
||||
import { TSurveyRedirectUrlCard } from "@formbricks/types/surveys/types";
|
||||
import { Input } from "@formbricks/ui/Input";
|
||||
import { Label } from "@formbricks/ui/Label";
|
||||
|
||||
interface RedirectUrlFormProps {
|
||||
endingCard: TSurveyRedirectUrlCard;
|
||||
updateSurvey: (input: Partial<TSurveyRedirectUrlCard>) => void;
|
||||
}
|
||||
|
||||
export const RedirectUrlForm = ({ endingCard, updateSurvey }: RedirectUrlFormProps) => {
|
||||
return (
|
||||
<form className="mt-3 space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label>URL</Label>
|
||||
<Input
|
||||
id="redirectUrl"
|
||||
name="redirectUrl"
|
||||
className="bg-white"
|
||||
placeholder="https://formbricks.com/signup"
|
||||
value={endingCard.url}
|
||||
onChange={(e) => updateSurvey({ url: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Label</Label>
|
||||
<Input
|
||||
id="redirectUrlLabel"
|
||||
name="redirectUrlLabel"
|
||||
className="bg-white"
|
||||
placeholder="Formbricks App"
|
||||
value={endingCard.label}
|
||||
onChange={(e) => updateSurvey({ label: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -26,11 +26,9 @@ export const ResponseOptionsCard = ({
|
||||
}: ResponseOptionsCardProps) => {
|
||||
const [open, setOpen] = useState(localSurvey.type === "link" ? true : false);
|
||||
const autoComplete = localSurvey.autoComplete !== null;
|
||||
const [redirectToggle, setRedirectToggle] = useState(false);
|
||||
const [runOnDateToggle, setRunOnDateToggle] = useState(false);
|
||||
const [closeOnDateToggle, setCloseOnDateToggle] = useState(false);
|
||||
useState;
|
||||
const [redirectUrl, setRedirectUrl] = useState<string | null>("");
|
||||
const [surveyClosedMessageToggle, setSurveyClosedMessageToggle] = useState(false);
|
||||
const [verifyEmailToggle, setVerifyEmailToggle] = useState(false);
|
||||
|
||||
@@ -45,10 +43,6 @@ export const ResponseOptionsCard = ({
|
||||
});
|
||||
|
||||
const [singleUseEncryption, setSingleUseEncryption] = useState(true);
|
||||
const [verifyEmailSurveyDetails, setVerifyEmailSurveyDetails] = useState({
|
||||
name: "",
|
||||
subheading: "",
|
||||
});
|
||||
const [runOnDate, setRunOnDate] = useState<Date | null>(null);
|
||||
const [closeOnDate, setCloseOnDate] = useState<Date | null>(null);
|
||||
|
||||
@@ -56,15 +50,6 @@ export const ResponseOptionsCard = ({
|
||||
|
||||
const [verifyProtectWithPinError, setVerifyProtectWithPinError] = useState<string | null>(null);
|
||||
|
||||
const handleRedirectCheckMark = () => {
|
||||
setRedirectToggle((prev) => !prev);
|
||||
|
||||
if (redirectToggle && localSurvey.redirectUrl) {
|
||||
setRedirectUrl(null);
|
||||
setLocalSurvey({ ...localSurvey, redirectUrl: null });
|
||||
}
|
||||
};
|
||||
|
||||
const handleRunOnDateToggle = () => {
|
||||
if (runOnDateToggle) {
|
||||
setRunOnDateToggle(false);
|
||||
@@ -116,11 +101,6 @@ export const ResponseOptionsCard = ({
|
||||
if (exceptThisSymbols.includes(e.key)) e.preventDefault();
|
||||
};
|
||||
|
||||
const handleRedirectUrlChange = (link: string) => {
|
||||
setRedirectUrl(link);
|
||||
setLocalSurvey({ ...localSurvey, redirectUrl: link });
|
||||
};
|
||||
|
||||
const handleCloseSurveyMessageToggle = () => {
|
||||
setSurveyClosedMessageToggle((prev) => !prev);
|
||||
|
||||
@@ -130,11 +110,8 @@ export const ResponseOptionsCard = ({
|
||||
};
|
||||
|
||||
const handleVerifyEmailToogle = () => {
|
||||
setVerifyEmailToggle((prev) => !prev);
|
||||
|
||||
if (verifyEmailToggle && localSurvey.verifyEmail) {
|
||||
setLocalSurvey({ ...localSurvey, verifyEmail: null });
|
||||
}
|
||||
setVerifyEmailToggle(!verifyEmailToggle);
|
||||
setLocalSurvey({ ...localSurvey, isVerifyEmailEnabled: !localSurvey.isVerifyEmailEnabled });
|
||||
};
|
||||
|
||||
const handleRunOnDateChange = (date: Date) => {
|
||||
@@ -219,28 +196,7 @@ export const ResponseOptionsCard = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleVerifyEmailSurveyDetailsChange = ({
|
||||
name,
|
||||
subheading,
|
||||
}: {
|
||||
name?: string;
|
||||
subheading?: string;
|
||||
}) => {
|
||||
const message = {
|
||||
name: name || verifyEmailSurveyDetails.name,
|
||||
subheading: subheading || verifyEmailSurveyDetails.subheading,
|
||||
};
|
||||
|
||||
setVerifyEmailSurveyDetails(message);
|
||||
setLocalSurvey({ ...localSurvey, verifyEmail: message });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (localSurvey.redirectUrl) {
|
||||
setRedirectUrl(localSurvey.redirectUrl);
|
||||
setRedirectToggle(true);
|
||||
}
|
||||
|
||||
if (!!localSurvey.surveyClosedMessage) {
|
||||
setSurveyClosedMessage({
|
||||
heading: localSurvey.surveyClosedMessage.heading ?? surveyClosedMessage.heading,
|
||||
@@ -257,11 +213,7 @@ export const ResponseOptionsCard = ({
|
||||
setSingleUseEncryption(localSurvey.singleUse.isEncrypted);
|
||||
}
|
||||
|
||||
if (localSurvey.verifyEmail) {
|
||||
setVerifyEmailSurveyDetails({
|
||||
name: localSurvey.verifyEmail.name!,
|
||||
subheading: localSurvey.verifyEmail.subheading!,
|
||||
});
|
||||
if (localSurvey.isVerifyEmailEnabled) {
|
||||
setVerifyEmailToggle(true);
|
||||
}
|
||||
|
||||
@@ -389,26 +341,6 @@ export const ResponseOptionsCard = ({
|
||||
</div>
|
||||
</AdvancedOptionToggle>
|
||||
|
||||
{/* Redirect on completion */}
|
||||
<AdvancedOptionToggle
|
||||
htmlId="redirectUrl"
|
||||
isChecked={redirectToggle}
|
||||
onToggle={handleRedirectCheckMark}
|
||||
title="Redirect on completion"
|
||||
description="Redirect user to link destination when they completed the survey"
|
||||
childBorder={true}>
|
||||
<div className="w-full p-4">
|
||||
<Input
|
||||
autoFocus
|
||||
className="w-full bg-white"
|
||||
type="url"
|
||||
placeholder="https://www.example.com"
|
||||
value={redirectUrl ? redirectUrl : ""}
|
||||
onChange={(e) => handleRedirectUrlChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</AdvancedOptionToggle>
|
||||
|
||||
{localSurvey.type === "link" && (
|
||||
<>
|
||||
{/* Adjust Survey Closed Message */}
|
||||
@@ -518,36 +450,8 @@ export const ResponseOptionsCard = ({
|
||||
onToggle={handleVerifyEmailToogle}
|
||||
title="Verify email before submission"
|
||||
description="Only let people with a real email respond."
|
||||
childBorder={true}>
|
||||
<div className="flex w-full items-center space-x-1 p-4 pb-4">
|
||||
<div className="w-full cursor-pointer items-center bg-slate-50">
|
||||
<Label htmlFor="howItWorks">How it works</Label>
|
||||
<p className="mb-4 mt-2 text-sm text-slate-500">
|
||||
Respondants will receive the survey link via email.
|
||||
</p>
|
||||
<Label htmlFor="headline">Survey Name (Public)</Label>
|
||||
<Input
|
||||
autoFocus
|
||||
id="heading"
|
||||
className="mb-4 mt-2 bg-white"
|
||||
name="heading"
|
||||
placeholder="Job Application Form"
|
||||
defaultValue={verifyEmailSurveyDetails.name}
|
||||
onChange={(e) => handleVerifyEmailSurveyDetailsChange({ name: e.target.value })}
|
||||
/>
|
||||
|
||||
<Label htmlFor="headline">Subheader (Public)</Label>
|
||||
<Input
|
||||
className="mt-2 bg-white"
|
||||
id="subheading"
|
||||
name="subheading"
|
||||
placeholder="Thanks for applying as a full stack engineer"
|
||||
defaultValue={verifyEmailSurveyDetails.subheading}
|
||||
onChange={(e) => handleVerifyEmailSurveyDetailsChange({ subheading: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</AdvancedOptionToggle>
|
||||
childBorder={true}
|
||||
/>
|
||||
<AdvancedOptionToggle
|
||||
htmlId="protectSurveyWithPin"
|
||||
isChecked={isPinProtectionEnabled}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { WhenToSendCard } from "./WhenToSendCard";
|
||||
|
||||
interface SettingsViewProps {
|
||||
environment: TEnvironment;
|
||||
organizationId: string;
|
||||
localSurvey: TSurvey;
|
||||
setLocalSurvey: (survey: TSurvey) => void;
|
||||
actionClasses: TActionClass[];
|
||||
@@ -29,6 +30,7 @@ interface SettingsViewProps {
|
||||
|
||||
export const SettingsView = ({
|
||||
environment,
|
||||
organizationId,
|
||||
localSurvey,
|
||||
setLocalSurvey,
|
||||
actionClasses,
|
||||
@@ -49,6 +51,7 @@ export const SettingsView = ({
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
environment={environment}
|
||||
product={product}
|
||||
organizationId={organizationId}
|
||||
/>
|
||||
|
||||
{localSurvey.type === "app" ? (
|
||||
|
||||
@@ -8,6 +8,7 @@ import { TActionClass } from "@formbricks/types/action-classes";
|
||||
import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TMembershipRole } from "@formbricks/types/memberships";
|
||||
import { TOrganizationBillingPlan } from "@formbricks/types/organizations";
|
||||
import { TProduct } from "@formbricks/types/product";
|
||||
import { TSegment } from "@formbricks/types/segment";
|
||||
import { TSurvey, TSurveyEditorTabs, TSurveyStyling } from "@formbricks/types/surveys/types";
|
||||
@@ -24,6 +25,7 @@ interface SurveyEditorProps {
|
||||
survey: TSurvey;
|
||||
product: TProduct;
|
||||
environment: TEnvironment;
|
||||
organizationId: string;
|
||||
actionClasses: TActionClass[];
|
||||
attributeClasses: TAttributeClass[];
|
||||
segments: TSegment[];
|
||||
@@ -34,12 +36,14 @@ interface SurveyEditorProps {
|
||||
isMultiLanguageAllowed?: boolean;
|
||||
isFormbricksCloud: boolean;
|
||||
isUnsplashConfigured: boolean;
|
||||
plan: TOrganizationBillingPlan;
|
||||
}
|
||||
|
||||
export const SurveyEditor = ({
|
||||
survey,
|
||||
product,
|
||||
environment,
|
||||
organizationId,
|
||||
actionClasses,
|
||||
attributeClasses,
|
||||
segments,
|
||||
@@ -50,6 +54,7 @@ export const SurveyEditor = ({
|
||||
isUserTargetingAllowed = false,
|
||||
isFormbricksCloud,
|
||||
isUnsplashConfigured,
|
||||
plan,
|
||||
}: SurveyEditorProps) => {
|
||||
const [activeView, setActiveView] = useState<TSurveyEditorTabs>("questions");
|
||||
const [activeQuestionId, setActiveQuestionId] = useState<string | null>(null);
|
||||
@@ -142,7 +147,7 @@ export const SurveyEditor = ({
|
||||
/>
|
||||
<div className="relative z-0 flex flex-1 overflow-hidden">
|
||||
<main
|
||||
className="relative z-0 w-1/2 flex-1 overflow-y-auto focus:outline-none"
|
||||
className="relative z-0 w-1/2 flex-1 overflow-y-auto bg-slate-50 focus:outline-none"
|
||||
ref={surveyEditorRef}>
|
||||
<QuestionsAudienceTabs
|
||||
activeId={activeView}
|
||||
@@ -164,6 +169,7 @@ export const SurveyEditor = ({
|
||||
isMultiLanguageAllowed={isMultiLanguageAllowed}
|
||||
isFormbricksCloud={isFormbricksCloud}
|
||||
attributeClasses={attributeClasses}
|
||||
plan={plan}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -185,6 +191,7 @@ export const SurveyEditor = ({
|
||||
{activeView === "settings" && (
|
||||
<SettingsView
|
||||
environment={environment}
|
||||
organizationId={organizationId}
|
||||
localSurvey={localSurvey}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
actionClasses={actionClasses}
|
||||
@@ -199,7 +206,7 @@ export const SurveyEditor = ({
|
||||
)}
|
||||
</main>
|
||||
|
||||
<aside className="group hidden flex-1 flex-shrink-0 items-center justify-center overflow-hidden border-l border-slate-100 bg-slate-50 py-6 md:flex md:flex-col">
|
||||
<aside className="group hidden flex-1 flex-shrink-0 items-center justify-center overflow-hidden border-l border-slate-200 bg-slate-100 py-6 shadow-inner md:flex md:flex-col">
|
||||
<PreviewSurvey
|
||||
survey={localSurvey}
|
||||
questionId={activeQuestionId}
|
||||
|
||||
@@ -11,7 +11,14 @@ import { getLanguageLabel } from "@formbricks/lib/i18n/utils";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TProduct } from "@formbricks/types/product";
|
||||
import { TSegment } from "@formbricks/types/segment";
|
||||
import { TSurvey, TSurveyEditorTabs, TSurveyQuestion, ZSurvey } from "@formbricks/types/surveys/types";
|
||||
import {
|
||||
TSurvey,
|
||||
TSurveyEditorTabs,
|
||||
TSurveyQuestion,
|
||||
ZSurvey,
|
||||
ZSurveyEndScreenCard,
|
||||
ZSurveyRedirectUrlCard,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { AlertDialog } from "@formbricks/ui/AlertDialog";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { Input } from "@formbricks/ui/Input";
|
||||
@@ -142,6 +149,7 @@ export const SurveyMenuBar = ({
|
||||
const localSurveyValidation = ZSurvey.safeParse(localSurvey);
|
||||
if (!localSurveyValidation.success) {
|
||||
const currentError = localSurveyValidation.error.errors[0];
|
||||
|
||||
if (currentError.path[0] === "questions") {
|
||||
const questionIdx = currentError.path[1];
|
||||
const question: TSurveyQuestion = localSurvey.questions[questionIdx];
|
||||
@@ -154,9 +162,12 @@ export const SurveyMenuBar = ({
|
||||
setInvalidQuestions((prevInvalidQuestions) =>
|
||||
prevInvalidQuestions ? [...prevInvalidQuestions, "start"] : ["start"]
|
||||
);
|
||||
} else if (currentError.path[0] === "thankYouCard") {
|
||||
} else if (currentError.path[0] === "endings") {
|
||||
const endingIdx = typeof currentError.path[1] === "number" ? currentError.path[1] : -1;
|
||||
setInvalidQuestions((prevInvalidQuestions) =>
|
||||
prevInvalidQuestions ? [...prevInvalidQuestions, "end"] : ["end"]
|
||||
prevInvalidQuestions
|
||||
? [...prevInvalidQuestions, localSurvey.endings[endingIdx].id]
|
||||
: [localSurvey.endings[endingIdx].id]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -204,6 +215,14 @@ export const SurveyMenuBar = ({
|
||||
return rest;
|
||||
});
|
||||
|
||||
localSurvey.endings = localSurvey.endings.map((ending) => {
|
||||
if (ending.type === "redirectToUrl") {
|
||||
return ZSurveyRedirectUrlCard.parse(ending);
|
||||
} else {
|
||||
return ZSurveyEndScreenCard.parse(ending);
|
||||
}
|
||||
});
|
||||
|
||||
const segment = await handleSegmentUpdate();
|
||||
const updatedSurvey = await updateSurveyAction({ ...localSurvey, segment });
|
||||
|
||||
@@ -317,7 +336,6 @@ export const SurveyMenuBar = ({
|
||||
{localSurvey.status !== "draft" && (
|
||||
<Button
|
||||
disabled={disableSave}
|
||||
variant="darkCTA"
|
||||
className="mr-3"
|
||||
loading={isSurveySaving}
|
||||
onClick={() => handleSaveAndGoBack()}>
|
||||
@@ -326,7 +344,6 @@ export const SurveyMenuBar = ({
|
||||
)}
|
||||
{localSurvey.status === "draft" && audiencePrompt && !isLinkSurvey && (
|
||||
<Button
|
||||
variant="darkCTA"
|
||||
onClick={() => {
|
||||
setAudiencePrompt(false);
|
||||
setActiveId("settings");
|
||||
@@ -339,7 +356,6 @@ export const SurveyMenuBar = ({
|
||||
{localSurvey.status === "draft" && (!audiencePrompt || isLinkSurvey) && (
|
||||
<Button
|
||||
disabled={isSurveySaving || containsEmptyTriggers}
|
||||
variant="darkCTA"
|
||||
loading={isSurveyPublishing}
|
||||
onClick={handleSurveyPublish}>
|
||||
Publish
|
||||
|
||||
@@ -34,9 +34,10 @@ export const UpdateQuestionId = ({
|
||||
}
|
||||
|
||||
const questionIds = localSurvey.questions.map((q) => q.id);
|
||||
const endingCardIds = localSurvey.endings.map((e) => e.id);
|
||||
const hiddenFieldIds = localSurvey.hiddenFields.fieldIds ?? [];
|
||||
|
||||
const validateIdError = validateId("Question", currentValue, questionIds, hiddenFieldIds);
|
||||
const validateIdError = validateId("Question", currentValue, questionIds, endingCardIds, hiddenFieldIds);
|
||||
|
||||
if (validateIdError) {
|
||||
setIsInputInvalid(true);
|
||||
@@ -71,7 +72,7 @@ export const UpdateQuestionId = ({
|
||||
disabled={localSurvey.status !== "draft" && !question.isDraft}
|
||||
className={`h-10 ${isInputInvalid ? "border-red-300 focus:border-red-300" : ""}`}
|
||||
/>
|
||||
<Button variant="darkCTA" size="sm" onClick={saveAction} disabled={isButtonDisabled()}>
|
||||
<Button size="sm" onClick={saveAction} disabled={isButtonDisabled()}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
// formats the text to highlight specific parts of the text with slashes
|
||||
export const formatTextWithSlashes = (text: string) => {
|
||||
const regex = /\/(.*?)\\/g;
|
||||
const parts = text.split(regex);
|
||||
|
||||
return parts.map((part, index) => {
|
||||
// Check if the part was inside slashes
|
||||
if (index % 2 !== 0) {
|
||||
return (
|
||||
<span key={index} className="mx-1 rounded-md bg-slate-100 p-1 px-2 text-xs">
|
||||
{part}
|
||||
</span>
|
||||
);
|
||||
} else {
|
||||
return part;
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
// extend this object in order to add more validation rules
|
||||
import { toast } from "react-hot-toast";
|
||||
import { z } from "zod";
|
||||
import { extractLanguageCodes, getLocalizedValue } from "@formbricks/lib/i18n/utils";
|
||||
import { checkForEmptyFallBackValue } from "@formbricks/lib/utils/recall";
|
||||
import { ZSegmentFilters } from "@formbricks/types/segment";
|
||||
@@ -8,13 +9,14 @@ import {
|
||||
TSurvey,
|
||||
TSurveyCTAQuestion,
|
||||
TSurveyConsentQuestion,
|
||||
TSurveyEndScreenCard,
|
||||
TSurveyLanguage,
|
||||
TSurveyMatrixQuestion,
|
||||
TSurveyMultipleChoiceQuestion,
|
||||
TSurveyOpenTextQuestion,
|
||||
TSurveyPictureSelectionQuestion,
|
||||
TSurveyQuestion,
|
||||
TSurveyThankYouCard,
|
||||
TSurveyRedirectUrlCard,
|
||||
TSurveyWelcomeCard,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { findLanguageCodesForDuplicateLabels } from "@formbricks/types/surveys/validation";
|
||||
@@ -163,25 +165,33 @@ export const validateSurveyQuestionsInBatch = (
|
||||
return invalidQuestions;
|
||||
};
|
||||
|
||||
export const isCardValid = (
|
||||
card: TSurveyWelcomeCard | TSurveyThankYouCard,
|
||||
cardType: "start" | "end",
|
||||
surveyLanguages: TSurveyLanguage[]
|
||||
): boolean => {
|
||||
const defaultLanguageCode = "default";
|
||||
const isContentValid = (content: Record<string, string> | undefined) => {
|
||||
return (
|
||||
!content || content[defaultLanguageCode] === "" || isLabelValidForAllLanguages(content, surveyLanguages)
|
||||
);
|
||||
};
|
||||
const isContentValid = (content: Record<string, string> | undefined, surveyLanguages: TSurveyLanguage[]) => {
|
||||
return !content || isLabelValidForAllLanguages(content, surveyLanguages);
|
||||
};
|
||||
|
||||
return (
|
||||
(card.headline ? isLabelValidForAllLanguages(card.headline, surveyLanguages) : true) &&
|
||||
isContentValid(
|
||||
cardType === "start" ? (card as TSurveyWelcomeCard).html : (card as TSurveyThankYouCard).subheader
|
||||
) &&
|
||||
isContentValid(card.buttonLabel)
|
||||
);
|
||||
export const isWelcomeCardValid = (card: TSurveyWelcomeCard, surveyLanguages: TSurveyLanguage[]): boolean => {
|
||||
return isContentValid(card.headline, surveyLanguages) && isContentValid(card.html, surveyLanguages);
|
||||
};
|
||||
|
||||
export const isEndingCardValid = (
|
||||
card: TSurveyEndScreenCard | TSurveyRedirectUrlCard,
|
||||
surveyLanguages: TSurveyLanguage[]
|
||||
) => {
|
||||
if (card.type === "endScreen") {
|
||||
return (
|
||||
isContentValid(card.headline, surveyLanguages) &&
|
||||
isContentValid(card.subheader, surveyLanguages) &&
|
||||
isContentValid(card.buttonLabel, surveyLanguages)
|
||||
);
|
||||
} else {
|
||||
const parseResult = z.string().url().safeParse(card.url);
|
||||
if (parseResult.success) {
|
||||
return card.label?.trim() !== "";
|
||||
} else {
|
||||
toast.error("Invalid Redirect Url in Ending card");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const isSurveyValid = (survey: TSurvey, selectedLanguageCode: string) => {
|
||||
|
||||
@@ -80,10 +80,12 @@ const Page = async ({ params }) => {
|
||||
attributeClasses={attributeClasses}
|
||||
responseCount={responseCount}
|
||||
membershipRole={currentUserMembership?.role}
|
||||
organizationId={organization.id}
|
||||
colors={SURVEY_BG_COLORS}
|
||||
segments={segments}
|
||||
isUserTargetingAllowed={isUserTargetingAllowed}
|
||||
isMultiLanguageAllowed={isMultiLanguageAllowed}
|
||||
plan={organization.billing.plan}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
isUnsplashConfigured={UNSPLASH_ACCESS_KEY ? true : false}
|
||||
/>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getDefaultEndingCard, welcomeCardDefault } from "@formbricks/lib/templates";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
|
||||
export const minimalSurvey: TSurvey = {
|
||||
@@ -12,20 +13,11 @@ export const minimalSurvey: TSurvey = {
|
||||
displayOption: "displayOnce",
|
||||
autoClose: null,
|
||||
triggers: [],
|
||||
redirectUrl: null,
|
||||
recontactDays: null,
|
||||
displayLimit: null,
|
||||
welcomeCard: {
|
||||
enabled: false,
|
||||
headline: { default: "Welcome!" },
|
||||
html: { default: "Thanks for providing your feedback - let's go!" },
|
||||
timeToFinish: false,
|
||||
showResponseCount: false,
|
||||
},
|
||||
welcomeCard: welcomeCardDefault,
|
||||
questions: [],
|
||||
thankYouCard: {
|
||||
enabled: false,
|
||||
},
|
||||
endings: [getDefaultEndingCard([])],
|
||||
hiddenFields: {
|
||||
enabled: false,
|
||||
},
|
||||
@@ -44,4 +36,5 @@ export const minimalSurvey: TSurvey = {
|
||||
segment: null,
|
||||
languages: [],
|
||||
showLanguageSwitch: false,
|
||||
isVerifyEmailEnabled: false,
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import { getEnvironment } from "@formbricks/lib/environment/service";
|
||||
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
|
||||
import { getUser } from "@formbricks/lib/user/service";
|
||||
import { TProductConfigChannel, TProductConfigIndustry } from "@formbricks/types/product";
|
||||
import { TTemplateRole } from "@formbricks/types/templates";
|
||||
import { TemplateContainerWithPreview } from "./components/TemplateContainer";
|
||||
@@ -21,13 +22,18 @@ const Page = async ({ params, searchParams }: SurveyTemplateProps) => {
|
||||
const session = await getServerSession(authOptions);
|
||||
const environmentId = params.environmentId;
|
||||
|
||||
const [environment, product] = await Promise.all([
|
||||
if (!session) {
|
||||
throw new Error("Session not found");
|
||||
}
|
||||
|
||||
const [user, environment, product] = await Promise.all([
|
||||
getUser(session.user.id),
|
||||
getEnvironment(environmentId),
|
||||
getProductByEnvironmentId(environmentId),
|
||||
]);
|
||||
|
||||
if (!session) {
|
||||
throw new Error("Session not found");
|
||||
if (!user) {
|
||||
throw new Error("User not found");
|
||||
}
|
||||
|
||||
if (!product) {
|
||||
@@ -43,7 +49,7 @@ const Page = async ({ params, searchParams }: SurveyTemplateProps) => {
|
||||
return (
|
||||
<TemplateContainerWithPreview
|
||||
environmentId={environmentId}
|
||||
user={session.user}
|
||||
user={user}
|
||||
environment={environment}
|
||||
product={product}
|
||||
prefilledFilters={prefilledFilters}
|
||||
|
||||
@@ -24,10 +24,7 @@ export const ConfirmationPage = ({ environmentId }: ConfirmationPageProps) => {
|
||||
Thanks a lot for upgrading your Formbricks subscription.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="darkCTA"
|
||||
className="w-full justify-center"
|
||||
href={`/environments/${environmentId}/settings/billing`}>
|
||||
<Button className="w-full justify-center" href={`/environments/${environmentId}/settings/billing`}>
|
||||
Back to billing overview
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { formbricksEnabled } from "@/app/lib/formbricks";
|
||||
import type { Session } from "next-auth";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import formbricks from "@formbricks/js/app";
|
||||
@@ -10,7 +11,7 @@ type UsageAttributesUpdaterProps = {
|
||||
numSurveys: number;
|
||||
};
|
||||
|
||||
export const FormbricksClient = ({ session }) => {
|
||||
export const FormbricksClient = ({ session, userEmail }: { session: Session; userEmail: string }) => {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
@@ -20,13 +21,13 @@ export const FormbricksClient = ({ session }) => {
|
||||
apiHost: env.NEXT_PUBLIC_FORMBRICKS_API_HOST || "",
|
||||
userId: session.user.id,
|
||||
});
|
||||
formbricks.setEmail(session.user.email);
|
||||
formbricks.setEmail(userEmail);
|
||||
|
||||
formbricks.registerRouteChange();
|
||||
}, [session.user.email, session.user.id]);
|
||||
}, [session.user.id, userEmail]);
|
||||
|
||||
useEffect(() => {
|
||||
if (formbricksEnabled && session?.user && formbricks) {
|
||||
if (formbricksEnabled && session?.user?.id && formbricks) {
|
||||
initializeFormbricksAndSetupRouteChanges();
|
||||
}
|
||||
}, [session, pathname, searchParams, initializeFormbricksAndSetupRouteChanges]);
|
||||
|
||||
@@ -100,7 +100,7 @@ export const AttributeSettingsTab = async ({ attributeClass, setOpen }: Attribut
|
||||
</div>
|
||||
{attributeClass.type !== "automatic" && (
|
||||
<div className="flex space-x-2">
|
||||
<Button type="submit" variant="darkCTA" loading={isAttributeBeingSubmitted}>
|
||||
<Button type="submit" loading={isAttributeBeingSubmitted}>
|
||||
Save changes
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import { getResponsesByPersonId } from "@formbricks/lib/response/service";
|
||||
import { getSurveys } from "@formbricks/lib/survey/service";
|
||||
import { getUser } from "@formbricks/lib/user/service";
|
||||
import { TAttributeClass } from "@formbricks/types/attribute-classes";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
@@ -23,19 +24,26 @@ export const ResponseSection = async ({
|
||||
}: ResponseSectionProps) => {
|
||||
const responses = await getResponsesByPersonId(personId);
|
||||
const surveyIds = responses?.map((response) => response.surveyId) || [];
|
||||
const surveys: TSurvey[] = surveyIds.length === 0 ? [] : (await getSurveys(environment.id)) ?? [];
|
||||
const surveys: TSurvey[] = surveyIds.length === 0 ? [] : ((await getSurveys(environment.id)) ?? []);
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session) {
|
||||
throw new Error("No session found");
|
||||
}
|
||||
|
||||
const user = await getUser(session.user.id);
|
||||
|
||||
if (!user) {
|
||||
throw new Error("No user found");
|
||||
}
|
||||
|
||||
if (!responses) {
|
||||
throw new Error("No responses found");
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponseTimeline
|
||||
user={session.user}
|
||||
user={user}
|
||||
surveys={surveys}
|
||||
responses={responses}
|
||||
environment={environment}
|
||||
|
||||
@@ -117,7 +117,7 @@ export const BasicCreateSegmentModal = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button variant="darkCTA" size="sm" onClick={() => setOpen(true)} EndIcon={PlusIcon}>
|
||||
<Button size="sm" onClick={() => setOpen(true)} EndIcon={PlusIcon}>
|
||||
Create segment
|
||||
</Button>
|
||||
|
||||
@@ -239,7 +239,6 @@ export const BasicCreateSegmentModal = ({
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="darkCTA"
|
||||
type="submit"
|
||||
loading={isCreatingSegment}
|
||||
disabled={isSaveDisabled}
|
||||
|
||||
@@ -236,7 +236,6 @@ export const BasicSegmentSettings = ({
|
||||
Delete
|
||||
</Button>
|
||||
<Button
|
||||
variant="darkCTA"
|
||||
type="submit"
|
||||
loading={isUpdatingSegment}
|
||||
onClick={() => {
|
||||
|
||||
@@ -7,30 +7,14 @@ import { getIsMultiOrgEnabled } from "@formbricks/ee/lib/service";
|
||||
import { authenticatedActionClient } from "@formbricks/lib/actionClient";
|
||||
import { checkAuthorization } from "@formbricks/lib/actionClient/utils";
|
||||
import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import { SHORT_URL_BASE, WEBAPP_URL } from "@formbricks/lib/constants";
|
||||
import { createMembership } from "@formbricks/lib/membership/service";
|
||||
import { createOrganization } from "@formbricks/lib/organization/service";
|
||||
import { createProduct } from "@formbricks/lib/product/service";
|
||||
import { createShortUrl } from "@formbricks/lib/shortUrl/service";
|
||||
import { updateUser } from "@formbricks/lib/user/service";
|
||||
import { AuthenticationError, AuthorizationError, OperationNotAllowedError } from "@formbricks/types/errors";
|
||||
import { getUser, updateUser } from "@formbricks/lib/user/service";
|
||||
import { AuthorizationError, OperationNotAllowedError } from "@formbricks/types/errors";
|
||||
import { ZProductUpdateInput } from "@formbricks/types/product";
|
||||
import { TUserNotificationSettings } from "@formbricks/types/user";
|
||||
|
||||
export const createShortUrlAction = async (url: string) => {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new AuthenticationError("Not authenticated");
|
||||
|
||||
const regexPattern = new RegExp("^" + WEBAPP_URL);
|
||||
const isValidUrl = regexPattern.test(url);
|
||||
|
||||
if (!isValidUrl) throw new Error("Only Formbricks survey URLs are allowed");
|
||||
|
||||
const shortUrl = await createShortUrl(url);
|
||||
const fullShortUrl = SHORT_URL_BASE + "/" + shortUrl.id;
|
||||
return fullShortUrl;
|
||||
};
|
||||
|
||||
export const createOrganizationAction = async (organizationName: string): Promise<Organization> => {
|
||||
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
|
||||
if (!isMultiOrgEnabled)
|
||||
@@ -40,6 +24,9 @@ export const createOrganizationAction = async (organizationName: string): Promis
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new AuthorizationError("Not authorized");
|
||||
|
||||
const user = await getUser(session.user.id);
|
||||
if (!user) throw new Error("User not found");
|
||||
|
||||
const newOrganization = await createOrganization({
|
||||
name: organizationName,
|
||||
});
|
||||
@@ -54,16 +41,16 @@ export const createOrganizationAction = async (organizationName: string): Promis
|
||||
});
|
||||
|
||||
const updatedNotificationSettings: TUserNotificationSettings = {
|
||||
...session.user.notificationSettings,
|
||||
...user.notificationSettings,
|
||||
alert: {
|
||||
...session.user.notificationSettings?.alert,
|
||||
...user.notificationSettings?.alert,
|
||||
},
|
||||
weeklySummary: {
|
||||
...session.user.notificationSettings?.weeklySummary,
|
||||
...user.notificationSettings?.weeklySummary,
|
||||
[product.id]: true,
|
||||
},
|
||||
unsubscribedOrganizationIds: Array.from(
|
||||
new Set([...(session.user.notificationSettings?.unsubscribedOrganizationIds || []), newOrganization.id])
|
||||
new Set([...(user.notificationSettings?.unsubscribedOrganizationIds || []), newOrganization.id])
|
||||
),
|
||||
};
|
||||
|
||||
|
||||
@@ -224,7 +224,7 @@ export const ActionSettingsTab = ({
|
||||
|
||||
{actionClass.type !== "automatic" && (
|
||||
<div className="flex space-x-2">
|
||||
<Button type="submit" variant="darkCTA" loading={isUpdatingAction}>
|
||||
<Button type="submit" loading={isUpdatingAction}>
|
||||
Save changes
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -27,12 +27,7 @@ export const AddActionModal = ({ environmentId, actionClasses }: AddActionModalP
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant="darkCTA"
|
||||
size="sm"
|
||||
loading={isLoading}
|
||||
onClick={() => setOpen(true)}
|
||||
EndIcon={PlusIcon}>
|
||||
<Button size="sm" loading={isLoading} onClick={() => setOpen(true)} EndIcon={PlusIcon}>
|
||||
Add Action
|
||||
</Button>
|
||||
<Modal open={open} setOpen={setOpen} noPadding closeOnOutsideClick={false} restrictOverflow>
|
||||
|
||||
@@ -12,8 +12,8 @@ import {
|
||||
getOrganizationsByUserId,
|
||||
} from "@formbricks/lib/organization/service";
|
||||
import { getProducts } from "@formbricks/lib/product/service";
|
||||
import { getUser } from "@formbricks/lib/user/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";
|
||||
|
||||
@@ -24,14 +24,19 @@ interface EnvironmentLayoutProps {
|
||||
}
|
||||
|
||||
export const EnvironmentLayout = async ({ environmentId, session, children }: EnvironmentLayoutProps) => {
|
||||
const [environment, organizations, organization] = await Promise.all([
|
||||
const [user, environment, organizations, organization] = await Promise.all([
|
||||
getUser(session.user.id),
|
||||
getEnvironment(environmentId),
|
||||
getOrganizationsByUserId(session.user.id),
|
||||
getOrganizationByEnvironmentId(environmentId),
|
||||
]);
|
||||
|
||||
if (!user) {
|
||||
throw new Error("User not found");
|
||||
}
|
||||
|
||||
if (!organization || !environment) {
|
||||
return <ErrorComponent />;
|
||||
throw new Error("Organization or environment not found");
|
||||
}
|
||||
|
||||
const [products, environments] = await Promise.all([
|
||||
@@ -40,7 +45,7 @@ export const EnvironmentLayout = async ({ environmentId, session, children }: En
|
||||
]);
|
||||
|
||||
if (!products || !environments || !organizations) {
|
||||
return <ErrorComponent />;
|
||||
throw new Error("Products, environments or organizations not found");
|
||||
}
|
||||
|
||||
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
|
||||
@@ -87,7 +92,7 @@ export const EnvironmentLayout = async ({ environmentId, session, children }: En
|
||||
organization={organization}
|
||||
organizations={organizations}
|
||||
products={products}
|
||||
session={session}
|
||||
user={user}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
membershipRole={currentUserMembership?.role}
|
||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
UserIcon,
|
||||
UsersIcon,
|
||||
} from "lucide-react";
|
||||
import type { Session } from "next-auth";
|
||||
import { signOut } from "next-auth/react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
@@ -33,6 +32,7 @@ import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TMembershipRole } from "@formbricks/types/memberships";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { TProduct } from "@formbricks/types/product";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import { ProfileAvatar } from "@formbricks/ui/Avatars";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { CreateOrganizationModal } from "@formbricks/ui/CreateOrganizationModal";
|
||||
@@ -53,7 +53,7 @@ import {
|
||||
interface NavigationProps {
|
||||
environment: TEnvironment;
|
||||
organizations: TOrganization[];
|
||||
session: Session;
|
||||
user: TUser;
|
||||
organization: TOrganization;
|
||||
products: TProduct[];
|
||||
isFormbricksCloud: boolean;
|
||||
@@ -65,7 +65,7 @@ export const MainNavigation = ({
|
||||
environment,
|
||||
organizations,
|
||||
organization,
|
||||
session,
|
||||
user,
|
||||
products,
|
||||
isFormbricksCloud,
|
||||
membershipRole,
|
||||
@@ -170,7 +170,7 @@ export const MainNavigation = ({
|
||||
isHidden: isViewer,
|
||||
},
|
||||
],
|
||||
[environment.id, pathname, isViewer]
|
||||
[environment.id, pathname, product?.config.channel, isViewer]
|
||||
);
|
||||
|
||||
const dropdownNavigation = [
|
||||
@@ -234,6 +234,7 @@ export const MainNavigation = ({
|
||||
</Link>
|
||||
)}
|
||||
<Button
|
||||
variant="minimal"
|
||||
size="icon"
|
||||
tooltipSide="right"
|
||||
onClick={toggleSidebar}
|
||||
@@ -352,7 +353,7 @@ export const MainNavigation = ({
|
||||
"flex cursor-pointer flex-row items-center space-x-5",
|
||||
isCollapsed ? "pl-2" : "pl-4"
|
||||
)}>
|
||||
<ProfileAvatar userId={session.user.id} imageUrl={session.user.imageUrl} />
|
||||
<ProfileAvatar userId={user.id} imageUrl={user.imageUrl} />
|
||||
{!isCollapsed && !isTextVisible && (
|
||||
<>
|
||||
<div className={cn(isTextVisible ? "opacity-0" : "opacity-100")}>
|
||||
@@ -360,10 +361,10 @@ export const MainNavigation = ({
|
||||
className={cn(
|
||||
"ph-no-capture ph-no-capture -mb-0.5 text-sm font-bold text-slate-700"
|
||||
)}>
|
||||
{session?.user?.name ? (
|
||||
<span>{truncate(session?.user?.name, 30)}</span>
|
||||
{user?.name ? (
|
||||
<span>{truncate(user?.name, 30)}</span>
|
||||
) : (
|
||||
<span>{truncate(session?.user?.email, 30)}</span>
|
||||
<span>{truncate(user?.email, 30)}</span>
|
||||
)}
|
||||
</p>
|
||||
<p className={cn("text-sm text-slate-500")}>
|
||||
|
||||
@@ -5,11 +5,13 @@ import { usePostHog } from "posthog-js/react";
|
||||
import { useEffect } from "react";
|
||||
import { env } from "@formbricks/lib/env";
|
||||
import { TOrganizationBilling } from "@formbricks/types/organizations";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
|
||||
const posthogEnabled = env.NEXT_PUBLIC_POSTHOG_API_KEY && env.NEXT_PUBLIC_POSTHOG_API_HOST;
|
||||
|
||||
interface PosthogIdentifyProps {
|
||||
session: Session;
|
||||
user: TUser;
|
||||
environmentId?: string;
|
||||
organizationId?: string;
|
||||
organizationName?: string;
|
||||
@@ -18,6 +20,7 @@ interface PosthogIdentifyProps {
|
||||
|
||||
export const PosthogIdentify = ({
|
||||
session,
|
||||
user,
|
||||
environmentId,
|
||||
organizationId,
|
||||
organizationName,
|
||||
@@ -28,10 +31,10 @@ export const PosthogIdentify = ({
|
||||
useEffect(() => {
|
||||
if (posthogEnabled && session.user && posthog) {
|
||||
posthog.identify(session.user.id, {
|
||||
name: session.user.name,
|
||||
email: session.user.email,
|
||||
role: session.user.role,
|
||||
objective: session.user.objective,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
objective: user.objective,
|
||||
});
|
||||
if (environmentId) {
|
||||
posthog.group("environment", environmentId, { name: environmentId });
|
||||
@@ -45,7 +48,18 @@ export const PosthogIdentify = ({
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [posthog, session.user, environmentId, organizationId, organizationName, organizationBilling]);
|
||||
}, [
|
||||
posthog,
|
||||
session.user,
|
||||
environmentId,
|
||||
organizationId,
|
||||
organizationName,
|
||||
organizationBilling,
|
||||
user.name,
|
||||
user.email,
|
||||
user.role,
|
||||
user.objective,
|
||||
]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
import clsx from "clsx";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { Input } from "@formbricks/ui/Input";
|
||||
import { Label } from "@formbricks/ui/Label";
|
||||
import { createShortUrlAction } from "../actions";
|
||||
|
||||
type UrlShortenerFormDataProps = {
|
||||
url: string;
|
||||
};
|
||||
type UrlValidationState = "default" | "valid" | "invalid";
|
||||
|
||||
export const UrlShortenerForm = ({ webAppUrl }: { webAppUrl: string }) => {
|
||||
const [urlValidationState, setUrlValidationState] = useState<UrlValidationState>("default");
|
||||
const [shortUrl, setShortUrl] = useState("");
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
watch,
|
||||
formState: { isSubmitting },
|
||||
} = useForm<UrlShortenerFormDataProps>({
|
||||
mode: "onSubmit",
|
||||
defaultValues: {
|
||||
url: "",
|
||||
},
|
||||
});
|
||||
|
||||
const handleUrlValidation = () => {
|
||||
const value = watch("url").trim();
|
||||
if (!value) {
|
||||
setUrlValidationState("default");
|
||||
return;
|
||||
}
|
||||
|
||||
const regexPattern = new RegExp("^" + webAppUrl);
|
||||
const isValid = regexPattern.test(value);
|
||||
if (!isValid) {
|
||||
setUrlValidationState("invalid");
|
||||
toast.error("Only formbricks survey links allowed.");
|
||||
} else {
|
||||
setUrlValidationState("valid");
|
||||
}
|
||||
};
|
||||
|
||||
const shortenUrl = async (data: UrlShortenerFormDataProps) => {
|
||||
if (urlValidationState !== "valid") return;
|
||||
|
||||
const shortUrl = await createShortUrlAction(data.url.trim());
|
||||
setShortUrl(shortUrl);
|
||||
};
|
||||
|
||||
const copyShortUrlToClipboard = () => {
|
||||
navigator.clipboard.writeText(shortUrl);
|
||||
toast.success("URL copied to clipboard!");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2 p-4">
|
||||
<form onSubmit={handleSubmit(shortenUrl)}>
|
||||
<div className="w-full space-y-2 rounded-lg">
|
||||
<Label>Paste Survey Link</Label>
|
||||
<div className="flex gap-3">
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder={`${webAppUrl}...`}
|
||||
className={clsx(
|
||||
"",
|
||||
urlValidationState === "valid"
|
||||
? "border-green-500 bg-green-50"
|
||||
: urlValidationState === "invalid"
|
||||
? "border-red-200 bg-red-50"
|
||||
: "border-slate-200"
|
||||
)}
|
||||
{...register("url", {
|
||||
required: true,
|
||||
})}
|
||||
onBlur={handleUrlValidation}
|
||||
/>
|
||||
<Button
|
||||
disabled={watch("url") === ""}
|
||||
variant="darkCTA"
|
||||
size="sm"
|
||||
type="submit"
|
||||
loading={isSubmitting}>
|
||||
Shorten
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{shortUrl && (
|
||||
<div className="w-full space-y-2 rounded-lg">
|
||||
<Label>Short Link</Label>
|
||||
<div className="flex gap-3">
|
||||
<span
|
||||
className="h-10 w-full cursor-pointer rounded-md border border-slate-300 bg-slate-100 px-3 py-2 text-sm text-slate-700"
|
||||
onClick={() => {
|
||||
if (shortUrl) {
|
||||
copyShortUrlToClipboard();
|
||||
}
|
||||
}}>
|
||||
{shortUrl}
|
||||
</span>
|
||||
<Button
|
||||
disabled={shortUrl === ""}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
type="button"
|
||||
onClick={() => copyShortUrlToClipboard()}>
|
||||
<span>Copy</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,34 +0,0 @@
|
||||
import { LinkIcon } from "lucide-react";
|
||||
import { Modal } from "@formbricks/ui/Modal";
|
||||
import { UrlShortenerForm } from "./UrlShortenerForm";
|
||||
|
||||
type UrlShortenerModalProps = {
|
||||
open: boolean;
|
||||
setOpen: (v: boolean) => void;
|
||||
webAppUrl: string;
|
||||
};
|
||||
|
||||
export const UrlShortenerModal = ({ open, setOpen, webAppUrl }: UrlShortenerModalProps) => {
|
||||
return (
|
||||
<Modal open={open} setOpen={setOpen} noPadding closeOnOutsideClick={false}>
|
||||
<div className="flex h-full flex-col rounded-lg pb-4">
|
||||
<div className="rounded-t-lg bg-slate-100">
|
||||
<div className="flex items-center justify-between p-6">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="mr-1.5 h-10 w-10 text-slate-500">
|
||||
<LinkIcon className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xl font-medium text-slate-700">URL shortener</div>
|
||||
<div className="text-sm text-slate-500">
|
||||
Create a short URL to make URL params less obvious.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<UrlShortenerForm webAppUrl={webAppUrl} />
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -354,9 +354,7 @@ export const AddIntegrationModal = ({
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button variant="darkCTA" type="submit">
|
||||
Save
|
||||
</Button>
|
||||
<Button type="submit">Save</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -83,8 +83,7 @@ export const ManageIntegration = (props: ManageIntegrationProps) => {
|
||||
onClick={() => {
|
||||
setDefaultValues(null);
|
||||
handleModal(true);
|
||||
}}
|
||||
variant="darkCTA">
|
||||
}}>
|
||||
Link new table
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -288,7 +288,7 @@ export const AddIntegrationModal = ({
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="darkCTA" type="submit" loading={isLinkingSheet}>
|
||||
<Button type="submit" loading={isLinkingSheet}>
|
||||
{selectedIntegration ? "Update" : "Link Sheet"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -67,7 +67,6 @@ export const ManageIntegration = ({
|
||||
<span className="text-slate-500">Connected with {googleSheetIntegration.config.email}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="darkCTA"
|
||||
onClick={() => {
|
||||
setSelectedIntegration(null);
|
||||
setOpenAddIntegrationModal(true);
|
||||
|
||||
@@ -6,9 +6,7 @@ const Loading = () => {
|
||||
<div className="mt-6 p-6">
|
||||
<GoBackButton />
|
||||
<div className="mb-6 text-right">
|
||||
<Button
|
||||
variant="darkCTA"
|
||||
className="pointer-events-none animate-pulse cursor-not-allowed select-none bg-slate-200">
|
||||
<Button className="pointer-events-none animate-pulse cursor-not-allowed select-none bg-slate-200">
|
||||
Link new sheet
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -518,7 +518,6 @@ export const AddIntegrationModal = ({
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="darkCTA"
|
||||
type="submit"
|
||||
loading={isLinkingDatabase}
|
||||
disabled={mapping.filter((m) => m.error).length > 0}>
|
||||
|
||||
@@ -63,7 +63,6 @@ export const ManageIntegration = ({
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="darkCTA"
|
||||
onClick={() => {
|
||||
setSelectedIntegration(null);
|
||||
setOpenAddIntegrationModal(true);
|
||||
|
||||
@@ -6,9 +6,7 @@ const Loading = () => {
|
||||
<div className="mt-6 p-6">
|
||||
<GoBackButton />
|
||||
<div className="mb-6 text-right">
|
||||
<Button
|
||||
variant="darkCTA"
|
||||
className="pointer-events-none animate-pulse cursor-not-allowed select-none bg-slate-200">
|
||||
<Button className="pointer-events-none animate-pulse cursor-not-allowed select-none bg-slate-200">
|
||||
Link new database
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -284,7 +284,7 @@ export const AddChannelMappingModal = ({
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="darkCTA" type="submit" loading={isLinkingChannel}>
|
||||
<Button type="submit" loading={isLinkingChannel}>
|
||||
{selectedIntegration ? "Update" : "Link Channel"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -65,7 +65,6 @@ export const ManageIntegration = ({
|
||||
<span className="text-slate-500">Connected with {slackIntegration.config.key.team.name}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="darkCTA"
|
||||
onClick={() => {
|
||||
refreshChannels();
|
||||
setSelectedIntegration(null);
|
||||
|
||||
@@ -17,7 +17,6 @@ export const AddWebhookButton = ({ environment, surveys }: AddWebhookButtonProps
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant="darkCTA"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setAddWebhookModalOpen(true);
|
||||
|
||||
@@ -228,7 +228,7 @@ export const AddWebhookModal = ({ environmentId, surveys, open, setOpen }: AddWe
|
||||
}}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="darkCTA" type="submit" loading={creatingWebhook}>
|
||||
<Button type="submit" loading={creatingWebhook}>
|
||||
Add Webhook
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -218,7 +218,7 @@ export const WebhookSettingsTab = ({ environmentId, webhook, surveys, setOpen }:
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-2">
|
||||
<Button type="submit" variant="darkCTA" loading={isUpdatingWebhook}>
|
||||
<Button type="submit" loading={isUpdatingWebhook}>
|
||||
Save changes
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
|
||||
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
|
||||
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
|
||||
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
|
||||
import { getUser } from "@formbricks/lib/user/service";
|
||||
import { AuthorizationError } from "@formbricks/types/errors";
|
||||
import { ToasterClient } from "@formbricks/ui/ToasterClient";
|
||||
import { FormbricksClient } from "../../components/FormbricksClient";
|
||||
@@ -17,6 +18,12 @@ const EnvLayout = async ({ children, params }) => {
|
||||
if (!session || !session.user) {
|
||||
return redirect(`/auth/login`);
|
||||
}
|
||||
|
||||
const user = await getUser(session.user.id);
|
||||
if (!user) {
|
||||
throw new Error("User not found");
|
||||
}
|
||||
|
||||
const hasAccess = await hasUserEnvironmentAccess(session.user.id, params.environmentId);
|
||||
if (!hasAccess) {
|
||||
throw new AuthorizationError("Not authorized");
|
||||
@@ -39,12 +46,13 @@ const EnvLayout = async ({ children, params }) => {
|
||||
<ResponseFilterProvider>
|
||||
<PosthogIdentify
|
||||
session={session}
|
||||
user={user}
|
||||
environmentId={params.environmentId}
|
||||
organizationId={organization.id}
|
||||
organizationName={organization.name}
|
||||
organizationBilling={organization.billing}
|
||||
/>
|
||||
<FormbricksClient session={session} />
|
||||
<FormbricksClient session={session} userEmail={user.email} />
|
||||
<ToasterClient />
|
||||
<EnvironmentLayout environmentId={params.environmentId} session={session}>
|
||||
{children}
|
||||
|
||||
@@ -63,9 +63,7 @@ export const AddApiKeyModal = ({ open, setOpen, onSubmit }: MemberModalProps) =>
|
||||
}}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="darkCTA" type="submit">
|
||||
Add API Key
|
||||
</Button>
|
||||
<Button type="submit">Add API Key</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -117,7 +117,6 @@ export const EditAPIKeys = ({
|
||||
|
||||
<div>
|
||||
<Button
|
||||
variant="darkCTA"
|
||||
size="sm"
|
||||
disabled={environmentId !== environmentTypeId}
|
||||
onClick={() => {
|
||||
|
||||
@@ -90,12 +90,7 @@ export const EditProductNameForm: React.FC<EditProductNameProps> = ({
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="darkCTA"
|
||||
size="sm"
|
||||
loading={isSubmitting}
|
||||
disabled={isSubmitting || !isDirty}>
|
||||
<Button type="submit" size="sm" loading={isSubmitting} disabled={isSubmitting || !isDirty}>
|
||||
Update
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
@@ -78,7 +78,6 @@ export const EditWaitingTimeForm: React.FC<EditWaitingTimeProps> = ({ product })
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="darkCTA"
|
||||
size="sm"
|
||||
className="w-fit"
|
||||
loading={isSubmitting}
|
||||
|
||||
@@ -169,7 +169,7 @@ export const EditLogo = ({ product, environmentId, isViewer }: EditLogoProps) =>
|
||||
</>
|
||||
)}
|
||||
{logoUrl && (
|
||||
<Button onClick={saveChanges} disabled={isLoading || isViewer} variant="darkCTA" size="sm">
|
||||
<Button onClick={saveChanges} disabled={isLoading || isViewer} size="sm">
|
||||
{isEditing ? "Save" : "Edit"}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -182,7 +182,7 @@ export const EditPlacementForm = ({ product }: EditPlacementProps) => {
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button variant="darkCTA" className="mt-4 w-fit" size="sm" loading={isSubmitting}>
|
||||
<Button className="mt-4 w-fit" size="sm" loading={isSubmitting}>
|
||||
Save
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
@@ -200,7 +200,7 @@ export const ThemeStyling = ({ product, environmentId, colors, isUnsplashConfigu
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<Button variant="darkCTA" size="sm" type="submit">
|
||||
<Button size="sm" type="submit">
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
|
||||
@@ -199,9 +199,7 @@ const Loading = () => {
|
||||
<div className={cn("absolute bottom-3 h-16 w-16 rounded bg-slate-700 sm:right-3")}></div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="darkCTA"
|
||||
className="pointer-events-none mt-4 animate-pulse cursor-not-allowed select-none bg-slate-200">
|
||||
<Button className="pointer-events-none mt-4 animate-pulse cursor-not-allowed select-none bg-slate-200">
|
||||
Loading
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -14,11 +14,12 @@ import { cn } from "@formbricks/lib/cn";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TTag, TTagsCount } from "@formbricks/types/tags";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { DeleteDialog } from "@formbricks/ui/DeleteDialog";
|
||||
import { EmptySpaceFiller } from "@formbricks/ui/EmptySpaceFiller";
|
||||
import { Input } from "@formbricks/ui/Input";
|
||||
import { LoadingSpinner } from "@formbricks/ui/LoadingSpinner";
|
||||
|
||||
interface IEditTagsWrapperProps {
|
||||
interface EditTagsWrapperProps {
|
||||
environment: TEnvironment;
|
||||
environmentTags: TTag[];
|
||||
environmentTagsCount: TTagsCount;
|
||||
@@ -40,10 +41,21 @@ const SingleTag: React.FC<{
|
||||
environmentTags,
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
// const { updateTag, updateTagError } = useUpdateTag(environment.id, tagId);
|
||||
// const { mergeTags, isMergingTags } = useMergeTags(environment.id);
|
||||
const [updateTagError, setUpdateTagError] = useState(false);
|
||||
const [isMergingTags, setIsMergingTags] = useState(false);
|
||||
const [openDeleteTagDialog, setOpenDeleteTagDialog] = useState(false);
|
||||
|
||||
const confirmDeleteTag = () => {
|
||||
deleteTagAction(tagId)
|
||||
.then((response) => {
|
||||
toast.success(`${response?.name ?? "Tag"} tag deleted`);
|
||||
updateTagsCount();
|
||||
router.refresh();
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(error?.message ?? "Something went wrong");
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full" key={tagId}>
|
||||
@@ -124,17 +136,16 @@ const SingleTag: React.FC<{
|
||||
size="sm"
|
||||
// loading={isDeletingTag}
|
||||
className="font-medium text-slate-50 focus:border-transparent focus:shadow-transparent focus:outline-transparent focus:ring-0 focus:ring-transparent"
|
||||
onClick={() => {
|
||||
if (confirm("Are you sure you want to delete this tag?")) {
|
||||
deleteTagAction(tagId).then(() => {
|
||||
toast.success("Tag deleted");
|
||||
updateTagsCount();
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
}}>
|
||||
onClick={() => setOpenDeleteTagDialog(true)}>
|
||||
Delete
|
||||
</Button>
|
||||
<DeleteDialog
|
||||
open={openDeleteTagDialog}
|
||||
setOpen={setOpenDeleteTagDialog}
|
||||
deleteWhat={tagName}
|
||||
text="Are you sure you want to delete this tag?"
|
||||
onDelete={confirmDeleteTag}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -142,7 +153,7 @@ const SingleTag: React.FC<{
|
||||
);
|
||||
};
|
||||
|
||||
export const EditTagsWrapper: React.FC<IEditTagsWrapperProps> = (props) => {
|
||||
export const EditTagsWrapper: React.FC<EditTagsWrapperProps> = (props) => {
|
||||
const { environment, environmentTags, environmentTagsCount } = props;
|
||||
return (
|
||||
<div className="">
|
||||
|
||||
@@ -6,7 +6,7 @@ import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
|
||||
import { deleteFile } from "@formbricks/lib/storage/service";
|
||||
import { getFileNameWithIdFromUrl } from "@formbricks/lib/storage/utils";
|
||||
import { updateUser } from "@formbricks/lib/user/service";
|
||||
import { getUser, updateUser } from "@formbricks/lib/user/service";
|
||||
import { AuthorizationError } from "@formbricks/types/errors";
|
||||
import { TUserUpdateInput } from "@formbricks/types/user";
|
||||
|
||||
@@ -81,22 +81,25 @@ export const updateAvatarAction = async (avatarUrl: string) => {
|
||||
|
||||
export const removeAvatarAction = async (environmentId: string) => {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session) {
|
||||
throw new Error("Not authenticated");
|
||||
}
|
||||
|
||||
if (!session.user.id) {
|
||||
throw new Error("User not found");
|
||||
}
|
||||
|
||||
const user = await getUser(session.user.id);
|
||||
if (!user) {
|
||||
throw new Error("User not found");
|
||||
}
|
||||
|
||||
const isUserAuthorized = await hasUserEnvironmentAccess(session.user.id, environmentId);
|
||||
if (!isUserAuthorized) {
|
||||
throw new Error("Not Authorized");
|
||||
}
|
||||
|
||||
try {
|
||||
const imageUrl = session.user.imageUrl;
|
||||
const imageUrl = user.imageUrl;
|
||||
if (!imageUrl) {
|
||||
throw new Error("Image not found");
|
||||
}
|
||||
|
||||
@@ -1,30 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { formbricksLogout } from "@/app/lib/formbricks";
|
||||
import { Session } from "next-auth";
|
||||
import type { Session } from "next-auth";
|
||||
import { useState } from "react";
|
||||
import { ProfileAvatar } from "@formbricks/ui/Avatars";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { DeleteAccountModal } from "@formbricks/ui/DeleteAccountModal";
|
||||
|
||||
export const EditAvatar = ({ session }) => {
|
||||
return (
|
||||
<div>
|
||||
<ProfileAvatar userId={session.user.id} imageUrl={session.user.imageUrl} />
|
||||
|
||||
<Button className="mt-4" variant="darkCTA" size="sm" disabled={true}>
|
||||
Upload Image
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const DeleteAccount = ({
|
||||
session,
|
||||
IS_FORMBRICKS_CLOUD,
|
||||
user,
|
||||
}: {
|
||||
session: Session | null;
|
||||
IS_FORMBRICKS_CLOUD: boolean;
|
||||
user: TUser;
|
||||
}) => {
|
||||
const [isModalOpen, setModalOpen] = useState(false);
|
||||
|
||||
@@ -37,7 +27,7 @@ export const DeleteAccount = ({
|
||||
<DeleteAccountModal
|
||||
open={isModalOpen}
|
||||
setOpen={setModalOpen}
|
||||
session={session}
|
||||
user={user}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
formbricksLogout={formbricksLogout}
|
||||
/>
|
||||
|
||||
@@ -145,9 +145,7 @@ export const DisableTwoFactorModal = ({ open, setOpen }: TDisableTwoFactorModalP
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button variant="darkCTA" size="sm">
|
||||
Disable
|
||||
</Button>
|
||||
<Button size="sm">Disable</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -19,9 +19,10 @@ import { FormError, FormField, FormItem, FormProvider } from "@formbricks/ui/For
|
||||
interface EditProfileAvatarFormProps {
|
||||
session: Session;
|
||||
environmentId: string;
|
||||
imageUrl: string | null;
|
||||
}
|
||||
|
||||
export const EditProfileAvatarForm = ({ session, environmentId }: EditProfileAvatarFormProps) => {
|
||||
export const EditProfileAvatarForm = ({ session, environmentId, imageUrl }: EditProfileAvatarFormProps) => {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
@@ -57,7 +58,7 @@ export const EditProfileAvatarForm = ({ session, environmentId }: EditProfileAva
|
||||
const handleUpload = async (file: File, environmentId: string) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
if (session?.user.imageUrl) {
|
||||
if (imageUrl) {
|
||||
// If avatar image already exists, then remove it before update action
|
||||
await removeAvatarAction(environmentId);
|
||||
}
|
||||
@@ -115,7 +116,7 @@ export const EditProfileAvatarForm = ({ session, environmentId }: EditProfileAva
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ProfileAvatar userId={session.user.id} imageUrl={session.user.imageUrl} />
|
||||
<ProfileAvatar userId={session.user.id} imageUrl={imageUrl} />
|
||||
</div>
|
||||
|
||||
<FormProvider {...form}>
|
||||
@@ -134,7 +135,7 @@ export const EditProfileAvatarForm = ({ session, environmentId }: EditProfileAva
|
||||
onClick={() => {
|
||||
inputRef.current?.click();
|
||||
}}>
|
||||
{session?.user.imageUrl ? "Change Image" : "Upload Image"}
|
||||
{imageUrl ? "Change Image" : "Upload Image"}
|
||||
<input
|
||||
type="file"
|
||||
id="hiddenFileInput"
|
||||
@@ -152,7 +153,7 @@ export const EditProfileAvatarForm = ({ session, environmentId }: EditProfileAva
|
||||
/>
|
||||
</Button>
|
||||
|
||||
{session?.user?.imageUrl && (
|
||||
{imageUrl && (
|
||||
<Button type="button" className="mr-2" variant="warn" size="sm" onClick={handleRemove}>
|
||||
Remove Image
|
||||
</Button>
|
||||
|
||||
@@ -67,7 +67,6 @@ export const EditProfileDetailsForm = ({ user }: { user: TUser }) => {
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="darkCTA"
|
||||
className="mt-4"
|
||||
size="sm"
|
||||
loading={isSubmitting}
|
||||
|
||||
@@ -100,9 +100,7 @@ const ConfirmPasswordForm = ({
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button variant="darkCTA" size="sm">
|
||||
Confirm
|
||||
</Button>
|
||||
<Button size="sm">Confirm</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -134,7 +132,7 @@ const ScanQRCode = ({ dataUri, secret, setCurrentStep, setOpen }: TScanQRCodePro
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button variant="darkCTA" size="sm" onClick={() => setCurrentStep("enterCode")}>
|
||||
<Button size="sm" onClick={() => setCurrentStep("enterCode")}>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
@@ -207,9 +205,7 @@ const EnterCode = ({ setCurrentStep, setOpen, refreshData }: TEnableCodeProps) =
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button variant="darkCTA" size="sm">
|
||||
Confirm
|
||||
</Button>
|
||||
<Button size="sm">Confirm</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -259,7 +255,6 @@ const DisplayBackupCodes = ({ backupCodes, setOpen }: TDisplayBackupCodesProps)
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="darkCTA"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(backupCodes.map((code) => formatBackupCode(code)).join("\n"));
|
||||
@@ -269,7 +264,6 @@ const DisplayBackupCodes = ({ backupCodes, setOpen }: TDisplayBackupCodesProps)
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="darkCTA"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
handleDownloadBackupCode();
|
||||
|
||||
@@ -18,6 +18,7 @@ const Page = async ({ params }: { params: { environmentId: string } }) => {
|
||||
if (!session) {
|
||||
throw new Error("Session not found");
|
||||
}
|
||||
|
||||
const user = session && session.user ? await getUser(session.user.id) : null;
|
||||
|
||||
return (
|
||||
@@ -33,7 +34,13 @@ const Page = async ({ params }: { params: { environmentId: string } }) => {
|
||||
<SettingsCard
|
||||
title="Avatar"
|
||||
description="Assist your organization in identifying you on Formbricks.">
|
||||
<EditProfileAvatarForm session={session} environmentId={environmentId} />
|
||||
{user && (
|
||||
<EditProfileAvatarForm
|
||||
session={session}
|
||||
environmentId={environmentId}
|
||||
imageUrl={user.imageUrl}
|
||||
/>
|
||||
)}
|
||||
</SettingsCard>
|
||||
{user.identityProvider === "email" && (
|
||||
<SettingsCard title="Security" description="Manage your password and other security settings.">
|
||||
@@ -44,7 +51,7 @@ const Page = async ({ params }: { params: { environmentId: string } }) => {
|
||||
<SettingsCard
|
||||
title="Delete account"
|
||||
description="Delete your account with all of your personal information and data.">
|
||||
<DeleteAccount session={session} IS_FORMBRICKS_CLOUD={IS_FORMBRICKS_CLOUD} />
|
||||
<DeleteAccount session={session} IS_FORMBRICKS_CLOUD={IS_FORMBRICKS_CLOUD} user={user} />
|
||||
</SettingsCard>
|
||||
<SettingsId title="Profile" id={user.id}></SettingsId>
|
||||
</div>
|
||||
|
||||
@@ -170,10 +170,7 @@ const Page = async ({ params }) => {
|
||||
No call needed, no strings attached: Request a free 30-day trial license to test all features
|
||||
by filling out this form:
|
||||
</p>
|
||||
<Button
|
||||
variant="darkCTA"
|
||||
href="https://app.formbricks.com/s/clvupq3y205i5yrm3sm9v1xt5"
|
||||
target="_blank">
|
||||
<Button href="https://app.formbricks.com/s/clvupq3y205i5yrm3sm9v1xt5" target="_blank">
|
||||
Request 30-day Trial License
|
||||
</Button>
|
||||
<p className="mt-2 text-xs text-slate-500">No credit card. No sales call. Just test it :)</p>
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from "@formbricks/lib/membership/service";
|
||||
import { verifyUserRoleAccess } from "@formbricks/lib/organization/auth";
|
||||
import { deleteOrganization, updateOrganization } from "@formbricks/lib/organization/service";
|
||||
import { getUser } from "@formbricks/lib/user/service";
|
||||
import {
|
||||
AuthenticationError,
|
||||
AuthorizationError,
|
||||
@@ -167,6 +168,11 @@ export const inviteUserAction = async (
|
||||
throw new AuthenticationError("Not authenticated");
|
||||
}
|
||||
|
||||
const user = await getUser(session.user.id);
|
||||
if (!user) {
|
||||
throw new Error("User not found");
|
||||
}
|
||||
|
||||
const isUserAuthorized = await hasOrganizationAuthority(session.user.id, organizationId);
|
||||
|
||||
if (INVITE_DISABLED) {
|
||||
@@ -192,7 +198,7 @@ export const inviteUserAction = async (
|
||||
});
|
||||
|
||||
if (invite) {
|
||||
await sendInviteMemberEmail(invite.id, email, session.user.name ?? "", name ?? "", false);
|
||||
await sendInviteMemberEmail(invite.id, email, user.name ?? "", name ?? "", false);
|
||||
}
|
||||
|
||||
return invite;
|
||||
|
||||
@@ -106,7 +106,7 @@ export const BulkInviteTab = ({ setOpen, onSubmit, canDoRoleManagement }: BulkIn
|
||||
Download CSV template
|
||||
</Button>
|
||||
</Link>
|
||||
<Button onClick={onImport} size="sm" variant="darkCTA" disabled={!csvFile}>
|
||||
<Button onClick={onImport} size="sm" disabled={!csvFile}>
|
||||
Import
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user