Compare commits

..

15 Commits

Author SHA1 Message Date
review-agent-prime[bot]
4b34763090 Edit packages/surveys/src/components/questions/OpenTextQuestion.tsx 2024-03-04 16:40:18 +00:00
pandeymangg
7a1af85141 fix: product settings 2024-03-04 22:07:13 +05:30
pandeymangg
2586a3ba3a feat: surveys package styling changes 2024-03-04 15:39:24 +05:30
pandeymangg
77408bf0b0 Merge branch 'main' of https://github.com/formbricks/formbricks into feat/form-styling 2024-03-04 13:19:00 +05:30
pandeymangg
d5b183155b feat: product settings page styling UI and service 2024-03-04 13:18:39 +05:30
pandeymangg
d7fc7995bc wip 2024-02-29 18:37:11 +05:30
pandeymangg
a9d8239a25 UI 2024-02-29 14:07:10 +05:30
pandeymangg
71bdb5095a fix: schema 2024-02-29 11:51:45 +05:30
pandeymangg
b84e322eee Merge branch 'main' into feat/form-styling 2024-02-29 11:49:41 +05:30
pandeymangg
08ccb954f3 fix: styling object 2024-02-28 14:43:10 +05:30
pandeymangg
38c6cb01df fix: styling object 2024-02-28 14:42:38 +05:30
pandeymangg
2c13121487 fix: data migration and product types 2024-02-28 12:04:06 +05:30
pandeymangg
73f1d09dc8 Merge branch 'main' into feat/form-styling 2024-02-28 11:24:42 +05:30
pandeymangg
1f884a408c feat: styling zod schema and data migration 2024-02-28 11:24:27 +05:30
pandeymangg
ed2253dcfc feat: styling zod schema and data migration 2024-02-28 11:23:58 +05:30
290 changed files with 7523 additions and 5738 deletions

View File

@@ -56,14 +56,11 @@ SMTP_PASSWORD=smtpPassword
# Uncomment the variables you would like to use and customize the values.
# Custom local storage path for file uploads
#UPLOADS_DIR=
##############
# S3 STORAGE #
##############
# S3 Storage is required for the file upload in serverless environments like Vercel
# S3 Storage is required for the file uplaod in serverless environments like Vercel
S3_ACCESS_KEY=
S3_SECRET_KEY=
S3_REGION=

View File

@@ -6,8 +6,6 @@ runs:
- name: Checkout repo
uses: actions/checkout@v3
- uses: ./.github/actions/dangerous-git-checkout
- name: Cache Build
uses: actions/cache@v3
id: cache-build

View File

@@ -1,118 +0,0 @@
name: Kamal Deploy
concurrency:
group: deploy-to-kamal
cancel-in-progress: false
on:
push:
branches:
- main
jobs:
Deploy:
runs-on: ubuntu-latest
environment: production
env:
DOCKER_BUILDKIT: 1
IS_FORMBRICKS_CLOUD: ${{ vars.IS_FORMBRICKS_CLOUD }}
WEBAPP_URL: ${{ vars.WEBAPP_URL }}
DATABASE_URL: ${{ secrets.DATABASE_URL }}
NEXTAUTH_SECRET: ${{ secrets.NEXTAUTH_SECRET }}
ENCRYPTION_KEY: ${{ secrets.ENCRYPTION_KEY }}
SHORT_URL_BASE: ${{ vars.SHORT_URL_BASE }}
MAIL_FROM: ${{ secrets.MAIL_FROM }}
SMTP_HOST: ${{ secrets.SMTP_HOST }}
SMTP_PORT: ${{ secrets.SMTP_PORT }}
SMTP_USER: ${{ secrets.SMTP_USER }}
SMTP_PASSWORD: ${{ secrets.SMTP_PASSWORD }}
PRIVACY_URL: ${{ vars.PRIVACY_URL }}
TERMS_URL: ${{ vars.TERMS_URL }}
IMPRINT_URL: ${{ vars.IMPRINT_URL }}
GITHUB_ID: ${{ secrets.GITHUB_ID }}
GITHUB_SECRET: ${{ secrets.GITHUB_SECRET }}
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }}
AZUREAD_CLIENT_ID: ${{ secrets.AZUREAD_CLIENT_ID }}
AZUREAD_CLIENT_SECRET: ${{ secrets.AZUREAD_CLIENT_SECRET }}
AZUREAD_TENANT_ID: ${{ secrets.AZUREAD_TENANT_ID }}
OIDC_CLIENT_ID: ${{ secrets.OIDC_CLIENT_ID }}
OIDC_CLIENT_SECRET: ${{ secrets.OIDC_CLIENT_SECRET }}
OIDC_ISSUER: ${{ secrets.OIDC_ISSUER }}
OIDC_DISPLAY_NAME: ${{ secrets.OIDC_DISPLAY_NAME }}
OIDC_SIGNING_ALGORITHM: ${{ secrets.OIDC_SIGNING_ALGORITHM }}
CRON_SECRET: ${{ secrets.CRON_SECRET }}
ASSET_PREFIX_URL: ${{ vars.ASSET_PREFIX_URL }}
NOTION_OAUTH_CLIENT_ID: ${{ secrets.NOTION_OAUTH_CLIENT_ID }}
NOTION_OAUTH_CLIENT_SECRET: ${{ secrets.NOTION_OAUTH_CLIENT_SECRET }}
STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}
STRIPE_WEBHOOK_SECRET: ${{ secrets.STRIPE_WEBHOOK_SECRET }}
GOOGLE_SHEETS_CLIENT_ID: ${{ secrets.GOOGLE_SHEETS_CLIENT_ID }}
GOOGLE_SHEETS_CLIENT_SECRET: ${{ secrets.GOOGLE_SHEETS_CLIENT_SECRET }}
GOOGLE_SHEETS_REDIRECT_URL: ${{ secrets.GOOGLE_SHEETS_REDIRECT_URL }}
AIRTABLE_CLIENT_ID: ${{ secrets.AIRTABLE_CLIENT_ID }}
ENTERPRISE_LICENSE_KEY: ${{ secrets.ENTERPRISE_LICENSE_KEY }}
DEFAULT_TEAM_ID: ${{ vars.DEFAULT_TEAM_ID }}
ONBOARDING_DISABLED: ${{ vars.ONBOARDING_DISABLED }}
CUSTOMER_IO_API_KEY: ${{ secrets.CUSTOMER_IO_API_KEY }}
CUSTOMER_IO_SITE_ID: ${{ secrets.CUSTOMER_IO_SITE_ID }}
NEXT_PUBLIC_POSTHOG_API_KEY: ${{ vars.NEXT_PUBLIC_POSTHOG_API_KEY }}
NEXT_PUBLIC_POSTHOG_API_HOST: ${{ vars.NEXT_PUBLIC_POSTHOG_API_HOST }}
NEXT_PUBLIC_FORMBRICKS_API_HOST: ${{ vars.NEXT_PUBLIC_FORMBRICKS_API_HOST }}
NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID: ${{ vars.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID }}
NEXT_PUBLIC_FORMBRICKS_ONBOARDING_SURVEY_ID: ${{ vars.NEXT_PUBLIC_FORMBRICKS_ONBOARDING_SURVEY_ID }}
NEXT_PUBLIC_SENTRY_DSN: ${{ vars.NEXT_PUBLIC_SENTRY_DSN }}
NODE_ENV: production
CLOUDFLARE_EMAIL: ${{ secrets.CLOUDFLARE_EMAIL }}
CLOUDFLARE_DNS_API_TOKEN: ${{ secrets.CLOUDFLARE_DNS_API_TOKEN }}
S3_ACCESS_KEY: ${{ secrets.S3_ACCESS_KEY }}
S3_SECRET_KEY: ${{ secrets.S3_SECRET_KEY }}
S3_REGION: ${{ vars.S3_REGION }}
S3_BUCKET_NAME: ${{ vars.S3_BUCKET_NAME }}
KAMAL_REGISTRY_PASSWORD: ${{ secrets.KAMAL_REGISTRY_PASSWORD }}
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: 3.3.0
bundler-cache: true
- name: Install dependencies
run: |
gem install kamal
- uses: webfactory/ssh-agent@v0.9.0
with:
ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v2
- name: Create builder
run: docker buildx create --use --name formbricks-gh-actions-builder
if: steps.buildx.outputs.should_create_builder == 'true'
- name: Push env variables to Kamal
run: |
kamal() { command kamal "$@" -c kamal/deploy.yml; }
kamal env push
- name: Run deploy command
run: |
kamal() { command kamal "$@" -c kamal/deploy.yml; }
set +e
DEPLOY_OUTPUT=$(kamal setup 2>&1)
DEPLOY_EXIT_CODE=$?
echo "$DEPLOY_OUTPUT"
if [[ "$DEPLOY_OUTPUT" == *"container not unhealthy (healthy)"* ]]; then
echo "Deployment reported healthy container. Considering as success."
kamal lock release
exit 0
else
exit $DEPLOY_EXIT_CODE
fi
shell: bash

View File

@@ -1,7 +1,7 @@
name: PR Update
on:
pull_request:
pull_request_target:
branches:
- main
merge_group:
@@ -30,7 +30,7 @@ jobs:
- "!(**.md|.github/CODEOWNERS)"
test:
name: Run Unit Tests
name: Run Tests
needs: [changes]
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
uses: ./.github/workflows/test.yml
@@ -58,7 +58,6 @@ jobs:
secrets: inherit
required:
name: PR Check Summary
needs: [lint, test, build, e2e-test]
if: always()
runs-on: ubuntu-latest

View File

@@ -3,7 +3,7 @@ on:
workflow_call:
jobs:
build:
name: Unit Tests
name: Tests
runs-on: ubuntu-latest
timeout-minutes: 15

View File

@@ -1,14 +0,0 @@
#!/bin/sh
# A sample post-deploy hook
#
# These environment variables are available:
# KAMAL_RECORDED_AT
# KAMAL_PERFORMER
# KAMAL_VERSION
# KAMAL_HOSTS
# KAMAL_ROLE (if set)
# KAMAL_DESTINATION (if set)
# KAMAL_RUNTIME
echo "$KAMAL_PERFORMER deployed $KAMAL_VERSION to $KAMAL_DESTINATION in $KAMAL_RUNTIME seconds"

View File

@@ -1,3 +0,0 @@
#!/bin/sh
echo "Rebooted Traefik on $KAMAL_HOSTS"

View File

@@ -1,51 +0,0 @@
#!/bin/sh
# A sample pre-build hook
#
# Checks:
# 1. We have a clean checkout
# 2. A remote is configured
# 3. The branch has been pushed to the remote
# 4. The version we are deploying matches the remote
#
# These environment variables are available:
# KAMAL_RECORDED_AT
# KAMAL_PERFORMER
# KAMAL_VERSION
# KAMAL_HOSTS
# KAMAL_ROLE (if set)
# KAMAL_DESTINATION (if set)
if [ -n "$(git status --porcelain)" ]; then
echo "Git checkout is not clean, aborting..." >&2
git status --porcelain >&2
exit 1
fi
first_remote=$(git remote)
if [ -z "$first_remote" ]; then
echo "No git remote set, aborting..." >&2
exit 1
fi
current_branch=$(git branch --show-current)
if [ -z "$current_branch" ]; then
echo "Not on a git branch, aborting..." >&2
exit 1
fi
remote_head=$(git ls-remote $first_remote --tags $current_branch | cut -f1)
if [ -z "$remote_head" ]; then
echo "Branch not pushed to remote, aborting..." >&2
exit 1
fi
if [ "$KAMAL_VERSION" != "$remote_head" ]; then
echo "Version ($KAMAL_VERSION) does not match remote HEAD ($remote_head), aborting..." >&2
exit 1
fi
exit 0

View File

@@ -1,47 +0,0 @@
#!/usr/bin/env ruby
# A sample pre-connect check
#
# Warms DNS before connecting to hosts in parallel
#
# These environment variables are available:
# KAMAL_RECORDED_AT
# KAMAL_PERFORMER
# KAMAL_VERSION
# KAMAL_HOSTS
# KAMAL_ROLE (if set)
# KAMAL_DESTINATION (if set)
# KAMAL_RUNTIME
hosts = ENV["KAMAL_HOSTS"].split(",")
results = nil
max = 3
elapsed = Benchmark.realtime do
results = hosts.map do |host|
Thread.new do
tries = 1
begin
Socket.getaddrinfo(host, 0, Socket::AF_UNSPEC, Socket::SOCK_STREAM, nil, Socket::AI_CANONNAME)
rescue SocketError
if tries < max
puts "Retrying DNS warmup: #{host}"
tries += 1
sleep rand
retry
else
puts "DNS warmup failed: #{host}"
host
end
end
tries
end
end.map(&:value)
end
retries = results.sum - hosts.size
nopes = results.count { |r| r == max }
puts "Prewarmed %d DNS lookups in %.2f sec: %d retries, %d failures" % [ hosts.size, elapsed, retries, nopes ]

View File

@@ -1,109 +0,0 @@
#!/usr/bin/env ruby
# A sample pre-deploy hook
#
# Checks the Github status of the build, waiting for a pending build to complete for up to 720 seconds.
#
# Fails unless the combined status is "success"
#
# These environment variables are available:
# KAMAL_RECORDED_AT
# KAMAL_PERFORMER
# KAMAL_VERSION
# KAMAL_HOSTS
# KAMAL_COMMAND
# KAMAL_SUBCOMMAND
# KAMAL_ROLE (if set)
# KAMAL_DESTINATION (if set)
# Only check the build status for production deployments
if ENV["KAMAL_COMMAND"] == "rollback" || ENV["KAMAL_DESTINATION"] != "production"
exit 0
end
require "bundler/inline"
# true = install gems so this is fast on repeat invocations
gemfile(true, quiet: true) do
source "https://rubygems.org"
gem "octokit"
gem "faraday-retry"
end
MAX_ATTEMPTS = 72
ATTEMPTS_GAP = 10
def exit_with_error(message)
$stderr.puts message
exit 1
end
class GithubStatusChecks
attr_reader :remote_url, :git_sha, :github_client, :combined_status
def initialize
@remote_url = `git config --get remote.origin.url`.strip.delete_prefix("https://github.com/")
@git_sha = `git rev-parse HEAD`.strip
@github_client = Octokit::Client.new(access_token: ENV["GITHUB_TOKEN"])
refresh!
end
def refresh!
@combined_status = github_client.combined_status(remote_url, git_sha)
end
def state
combined_status[:state]
end
def first_status_url
first_status = combined_status[:statuses].find { |status| status[:state] == state }
first_status && first_status[:target_url]
end
def complete_count
combined_status[:statuses].count { |status| status[:state] != "pending"}
end
def total_count
combined_status[:statuses].count
end
def current_status
if total_count > 0
"Completed #{complete_count}/#{total_count} checks, see #{first_status_url} ..."
else
"Build not started..."
end
end
end
$stdout.sync = true
puts "Checking build status..."
attempts = 0
checks = GithubStatusChecks.new
begin
loop do
case checks.state
when "success"
puts "Checks passed, see #{checks.first_status_url}"
exit 0
when "failure"
exit_with_error "Checks failed, see #{checks.first_status_url}"
when "pending"
attempts += 1
end
exit_with_error "Checks are still pending, gave up after #{MAX_ATTEMPTS * ATTEMPTS_GAP} seconds" if attempts == MAX_ATTEMPTS
puts checks.current_status
sleep(ATTEMPTS_GAP)
checks.refresh!
end
rescue Octokit::NotFound
exit_with_error "Build status could not be found"
end

View File

@@ -1,3 +0,0 @@
#!/bin/sh
echo "Rebooting Traefik on $KAMAL_HOSTS..."

View File

@@ -3,25 +3,25 @@ import {
ClockIcon,
CogIcon,
CreditCardIcon,
FileBarChartIcon,
HelpCircleIcon,
DocumentChartBarIcon,
HomeIcon,
QuestionMarkCircleIcon,
ScaleIcon,
ShieldCheckIcon,
UsersIcon,
} from "lucide-react";
UserGroupIcon,
} from "@heroicons/react/24/outline";
const navigation = [
{ name: "Home", href: "#", icon: HomeIcon, current: true },
{ name: "History", href: "#", icon: ClockIcon, current: false },
{ name: "Balances", href: "#", icon: ScaleIcon, current: false },
{ name: "Cards", href: "#", icon: CreditCardIcon, current: false },
{ name: "Recipients", href: "#", icon: UsersIcon, current: false },
{ name: "Reports", href: "#", icon: FileBarChartIcon, current: false },
{ name: "Recipients", href: "#", icon: UserGroupIcon, current: false },
{ name: "Reports", href: "#", icon: DocumentChartBarIcon, current: false },
];
const secondaryNavigation = [
{ name: "Settings", href: "#", icon: CogIcon },
{ name: "Help", href: "#", icon: HelpCircleIcon },
{ name: "Help", href: "#", icon: QuestionMarkCircleIcon },
{ name: "Privacy", href: "#", icon: ShieldCheckIcon },
];

View File

@@ -12,8 +12,8 @@
},
"dependencies": {
"@formbricks/js": "workspace:*",
"lucide-react": "^0.356.0",
"next": "14.1.3",
"@heroicons/react": "^2.1.1",
"next": "14.1.1",
"react": "18.2.0",
"react-dom": "18.2.0"
},

View File

@@ -99,6 +99,7 @@ if (typeof window !== "undefined") {
formbricks.init({
environmentId: "<environment-id>",
apiHost: "<api-host>",
debug: true, // remove when in production
});
}
@@ -181,6 +182,7 @@ useEffect(() => {
formbricks.init({
environmentId: "<environment-id>",
apiHost: "<api-host>",
debug: true, // remove when in production
});
}, []);
@@ -230,6 +232,7 @@ if (typeof window !== "undefined") {
formbricks.init({
environmentId: "<environment-id>",
apiHost: "<api-host>",
debug: true, // remove when in production
});
}
@@ -266,6 +269,14 @@ Refer to our [Example NextJS Pages Directory project](https://github.com/formbri
</Property>
</Properties>
### Optional Customizations to be Made
<Properties>
<Property name="debug" type="boolean">
Whether you want to see debug messages from Formbricks on your client-side console.
</Property>
</Properties>
### What are we doing here?
First we need to initialize the Formbricks SDK, making sure it only runs on the client side.
@@ -347,6 +358,14 @@ router.afterEach((to, from) => {
</Property>
</Properties>
### Optional Customizations to be Made
<Properties>
<Property name="debug" type="boolean">
Whether you want to see debug messages from Formbricks on your client-side console.
</Property>
</Properties>
Refer to our [Example VueJs project](https://github.com/formbricks/examples/tree/main/vuejs) for more help! Now visit the [Validate your Setup](#validate-your-setup) section to verify your setup!
## Validate your setup

View File

@@ -76,27 +76,6 @@ GOOGLE_CLIENT_SECRET=your-client-secret-here
- Navigate to your Docker setup directory where your `docker-compose.yml` file is located.
- Run the following command to bring down your current Docker containers and then bring them back up with the updated environment configuration:
## Azure SSO Integration
Have an Azure Active Directory (AAD) instance? Integrate it with your Formbricks instance to allow users to log in using their existing AAD credentials. This guide will walk you through the process of setting up Azure SSO for your Formbricks instance.
### Requirements
- An Azure Active Directory (AAD) instance.
- A Formbricks instance running and accessible.
### Steps
1. Create a new Tenant in Azure Active Directory as per their [official documentation](https://learn.microsoft.com/en-us/entra/fundamentals/create-new-tenant).
2. Add Users & Groups to your AAD instance.
3. Now we need to fill the below environment variables in our Formbricks instance so get them from your AD configuration:
- `AZUREAD_CLIENT_ID`
- `AZUREAD_CLIENT_SECRET`
- `AZUREAD_TENANT_ID`
4. Update these environment variables in your `docker-compose.yml` or pass it like your other environment variables to the Formbricks container.
5. Restart your Formbricks instance.
6. You're all set! Users can now signup & log in using their AAD credentials.
## OpenID Integration
Integrating your own OIDC (OpenID Connect) instance with your Formbricks instance allows users to log in using their OIDC credentials, ensuring a secure and streamlined user experience. Please follow the steps below to set up OIDC for your Formbricks instance.
@@ -136,53 +115,52 @@ OIDC_SIGNING_ALGORITHM=HS256
These variables can be provided at the runtime i.e. in your docker-compose file.
| Variable | Description | Required | Default |
|-----------------------------|----------------------------------------------------------------------------------------------|---------------------------------------------------------|---------------------------|
| WEBAPP_URL | Base URL of the site. | required | `http://localhost:3000` |
| DATABASE_URL | Database URL with credentials. | required | |
| NEXTAUTH_SECRET | Secret for NextAuth, used for session signing and encryption. | required | (Generated by the user) |
| ENCRYPTION_KEY | Secret for used by Formbricks for data encryption | required | (Generated by the user) |
| NEXTAUTH_URL | Location of the auth server. By default, this is the Formbricks docker instance itself. | required | `http://localhost:3000` |
| UPLOADS_DIR | Local directory for storing uploads. | optional | `./uploads` |
| S3_ACCESS_KEY | Access key for S3. | optional | (resolved by the AWS SDK) |
| S3_SECRET_KEY | Secret key for S3. | optional | (resolved by the AWS SDK) |
| S3_REGION | Region for S3. | optional | (resolved by the AWS SDK) |
| S3_BUCKET | Bucket name for S3. | optional (required if S3 is enabled) | |
| S3_ENDPOINT | Endpoint for S3. | optional | (resolved by the AWS SDK) |
| PRIVACY_URL | URL for privacy policy. | optional | |
| TERMS_URL | URL for terms of service. | optional | |
| IMPRINT_URL | URL for imprint. | optional | |
| SIGNUP_DISABLED | Disables the ability for new users to create an account if set to `1`. | optional | |
| EMAIL_AUTH_DISABLED | Disables the ability for users to signup or login via email and password if set to `1`. | optional | |
| PASSWORD_RESET_DISABLED | Disables password reset functionality if set to `1`. | optional | |
| EMAIL_VERIFICATION_DISABLED | Disables email verification if set to `1`. | optional | |
| RATE_LIMITING_DISABLED | Disables rate limiting if set to `1`. | optional | |
| INVITE_DISABLED | Disables the ability for invited users to create an account if set to `1`. | optional | |
| MAIL_FROM | Email address to send emails from. | optional (required if email services are to be enabled) | |
| SMTP_HOST | Host URL of your SMTP server. | optional (required if email services are to be enabled) | |
| SMTP_PORT | Host Port of your SMTP server. | optional (required if email services are to be enabled) | |
| SMTP_USER | Username for your SMTP Server. | optional (required if email services are to be enabled) | |
| SMTP_PASSWORD | Password for your SMTP Server. | optional (required if email services are to be enabled) | |
| SMTP_SECURE_ENABLED | SMTP secure connection. For using TLS, set to `1` else to `0`. | optional (required if email services are to be enabled) | |
| GITHUB_ID | Client ID for GitHub. | optional (required if GitHub auth is enabled) | |
| GITHUB_SECRET | Secret for GitHub. | optional (required if GitHub auth is enabled) | |
| GOOGLE_CLIENT_ID | Client ID for Google. | optional (required if Google auth is enabled) | |
| GOOGLE_CLIENT_SECRET | Secret for Google. | optional (required if Google auth is enabled) | |
| CRON_SECRET | API Secret for running cron jobs. | optional | |
| STRIPE_SECRET_KEY | Secret key for Stripe integration. | optional | |
| STRIPE_WEBHOOK_SECRET | Webhook secret for Stripe integration. | optional | |
| TELEMETRY_DISABLED | Disables telemetry if set to `1`. | optional | |
| INSTANCE_ID | Instance ID for Formbricks Cloud to be sent to Telemetry. | optional | |
| INTERNAL_SECRET | Internal Secret (Currently we overwrite the value with a random value). | optional | |
| DEFAULT_BRAND_COLOR | Default brand color for your app (Can be overwritten from the UI as well). | optional | `#64748b` |
| DEFAULT_TEAM_ID | Automatically assign new users to a specific team when joining | optional | |
| DEFAULT_TEAM_ROLE | Role of the user in the default team. | optional | `admin` |
| ONBOARDING_DISABLED | Disables onboarding for new users if set to `1` | optional | |
| OIDC_DISPLAY_NAME | Display name for Custom OpenID Connect Provider | optional | |
| OIDC_CLIENT_ID | Client ID for Custom OpenID Connect Provider | optional (required if OIDC auth is enabled) | |
| OIDC_CLIENT_SECRET | Secret for Custom OpenID Connect Provider | optional (required if OIDC auth is enabled) | |
| OIDC_ISSUER | Issuer URL for Custom OpenID Connect Provider (should have `.well-known` configured at this) | optional (required if OIDC auth is enabled) | |
| OIDC_SIGNING_ALGORITHM | Signing Algorithm for Custom OpenID Connect Provider | optional | `RS256` |
| Variable | Description | Required | Default |
| --------------------------- | -------------------------------------------------------------------------------------------- | ------------------------------------------------------- | ----------------------- |
| WEBAPP_URL | Base URL of the site. | required | `http://localhost:3000` |
| DATABASE_URL | Database URL with credentials. | required | |
| NEXTAUTH_SECRET | Secret for NextAuth, used for session signing and encryption. | required | (Generated by the user) |
| ENCRYPTION_KEY | Secret for used by Formbricks for data encryption | required | (Generated by the user) |
| NEXTAUTH_URL | Location of the auth server. By default, this is the Formbricks docker instance itself. | required | `http://localhost:3000` |
| S3_ACCESS_KEY | Access key for S3. | optional (required if S3 is enabled) | |
| S3_SECRET_KEY | Secret key for S3. | optional (required if S3 is enabled) | |
| S3_REGION | Region for S3. | optional (required if S3 is enabled) | |
| S3_BUCKET | Bucket name for S3. | optional (required if S3 is enabled) | |
| S3_ENDPOINT | Endpoint for S3. | optional (required if S3 is enabled) | |
| PRIVACY_URL | URL for privacy policy. | optional | |
| TERMS_URL | URL for terms of service. | optional | |
| IMPRINT_URL | URL for imprint. | optional | |
| SIGNUP_DISABLED | Disables the ability for new users to create an account if set to `1`. | optional | |
| EMAIL_AUTH_DISABLED | Disables the ability for users to signup or login via email and password if set to `1`. | optional | |
| PASSWORD_RESET_DISABLED | Disables password reset functionality if set to `1`. | optional | |
| EMAIL_VERIFICATION_DISABLED | Disables email verification if set to `1`. | optional | |
| RATE_LIMITING_DISABLED | Disables rate limiting if set to `1`. | optional | |
| INVITE_DISABLED | Disables the ability for invited users to create an account if set to `1`. | optional | |
| MAIL_FROM | Email address to send emails from. | optional (required if email services are to be enabled) | |
| SMTP_HOST | Host URL of your SMTP server. | optional (required if email services are to be enabled) | |
| SMTP_PORT | Host Port of your SMTP server. | optional (required if email services are to be enabled) | |
| SMTP_USER | Username for your SMTP Server. | optional (required if email services are to be enabled) | |
| SMTP_PASSWORD | Password for your SMTP Server. | optional (required if email services are to be enabled) | |
| SMTP_SECURE_ENABLED | SMTP secure connection. For using TLS, set to `1` else to `0`. | optional (required if email services are to be enabled) | |
| GITHUB_ID | Client ID for GitHub. | optional (required if GitHub auth is enabled) | |
| GITHUB_SECRET | Secret for GitHub. | optional (required if GitHub auth is enabled) | |
| GOOGLE_CLIENT_ID | Client ID for Google. | optional (required if Google auth is enabled) | |
| GOOGLE_CLIENT_SECRET | Secret for Google. | optional (required if Google auth is enabled) | |
| CRON_SECRET | API Secret for running cron jobs. | optional | |
| STRIPE_SECRET_KEY | Secret key for Stripe integration. | optional | |
| STRIPE_WEBHOOK_SECRET | Webhook secret for Stripe integration. | optional | |
| TELEMETRY_DISABLED | Disables telemetry if set to `1`. | optional | |
| INSTANCE_ID | Instance ID for Formbricks Cloud to be sent to Telemetry. | optional | |
| INTERNAL_SECRET | Internal Secret (Currently we overwrite the value with a random value). | optional | |
| DEFAULT_BRAND_COLOR | Default brand color for your app (Can be overwritten from the UI as well). | optional | `#64748b` |
| DEFAULT_TEAM_ID | Automatically assign new users to a specific team when joining | optional | |
| DEFAULT_TEAM_ROLE | Role of the user in the default team. | optional | `admin` |
| ONBOARDING_DISABLED | Disables onboarding for new users if set to `1` | optional | |
| OIDC_DISPLAY_NAME | Display name for Custom OpenID Connect Provider | optional | |
| OIDC_CLIENT_ID | Client ID for Custom OpenID Connect Provider | optional (required if OIDC auth is enabled) | |
| OIDC_CLIENT_SECRET | Secret for Custom OpenID Connect Provider | optional (required if OIDC auth is enabled) | |
| OIDC_ISSUER | Issuer URL for Custom OpenID Connect Provider (should have `.well-known` configured at this) | optional (required if OIDC auth is enabled) | |
| OIDC_SIGNING_ALGORITHM | Signing Algorithm for Custom OpenID Connect Provider | optional | `RS256` |
## Build-time Variables

View File

@@ -1,4 +1,4 @@
import { PlusIcon, TrashIcon } from "lucide-react";
import { PlusIcon, TrashIcon } from "@heroicons/react/24/solid";
import { useState } from "react";
import { Button } from "@formbricks/ui/Button";

View File

@@ -1,4 +1,4 @@
import { MousePointerClickIcon } from "lucide-react";
import { CursorArrowRaysIcon } from "@heroicons/react/24/solid";
import { Button } from "@formbricks/ui/Button";
import { Input } from "@formbricks/ui/Input";
@@ -20,7 +20,7 @@ export const AddNoCodeEventModalDummy: React.FC<EventDetailModalProps> = ({ open
<div className="p-4 sm:p-6">
<div className="flex items-center space-x-2">
<div className="h-6 w-6 text-slate-500">
<MousePointerClickIcon className="h-5 w-5" />
<CursorArrowRaysIcon />
</div>
<div>
<div className="text-lg font-medium text-slate-700 dark:text-slate-300">Add Action</div>

View File

@@ -1,8 +1,10 @@
import * as DOMPurify from "dompurify";
export default function HtmlBody({ htmlString, questionId }: { htmlString: string; questionId: string }) {
return (
<label
htmlFor={questionId}
className="fb-block fb-font-normal fb-leading-6 text-sm text-slate-500 dark:text-slate-300"
dangerouslySetInnerHTML={{ __html: htmlString }}></label>
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(htmlString) }}></label>
);
}

View File

@@ -8,7 +8,7 @@ import NILogoLight from "@/images/clients/niLogoWhite.svg";
import OptimoleLogo from "@/images/clients/optimole-logo.svg";
import ThemeisleLogo from "@/images/clients/themeisle-logo.webp";
import AnimationFallback from "@/public/animations/opensource-xm-platform-formbricks-fallback.png";
import { ShieldCheckIcon, StarIcon } from "lucide-react";
import { ShieldCheckIcon, StarIcon } from "@heroicons/react/24/outline";
import { usePlausible } from "next-plausible";
import Image from "next/image";
import { useRouter } from "next/router";

View File

@@ -1,5 +1,5 @@
import { ArrowUpIcon } from "@heroicons/react/24/solid";
import throttle from "lodash/throttle";
import { ArrowUpIcon } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { Button } from "@formbricks/ui/Button";

View File

@@ -1,7 +1,7 @@
import DemoPreview from "@/components/dummyUI/DemoPreview";
import DashboardMockupDark from "@/images/dashboard-mockup-dark.png";
import DashboardMockup from "@/images/dashboard-mockup.png";
import { MousePointerClickIcon } from "lucide-react";
import { CursorArrowRaysIcon } from "@heroicons/react/24/solid";
import Image from "next/image";
import { useState } from "react";
@@ -48,7 +48,7 @@ export const Steps: React.FC = () => {
<div className="order-last w-full rounded-lg bg-slate-100 p-4 sm:py-8 md:order-first dark:bg-slate-800">
<div className="flex h-40 items-center justify-center">
<Button variant="primary">
<MousePointerClickIcon className="mr-2 h-5 w-5 text-white" />
<CursorArrowRaysIcon className="mr-2 h-5 w-5 text-white" />
Add Action
</Button>
</div>

View File

@@ -1,7 +1,7 @@
"use client";
import { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/24/solid";
import clsx from "clsx";
import { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import { useState } from "react";
interface APICallProps {

View File

@@ -1,8 +1,8 @@
import GitHubMarkWhite from "@/images/github-mark-white.svg";
import GitHubMarkDark from "@/images/github-mark.svg";
import { Popover, Transition } from "@headlessui/react";
import { Bars3Icon, ChevronDownIcon, ChevronRightIcon, XMarkIcon } from "@heroicons/react/24/outline";
import clsx from "clsx";
import { ChevronDownIcon, ChevronRightIcon, MenuIcon, XIcon } from "lucide-react";
import { usePlausible } from "next-plausible";
import Image from "next/image";
import Link from "next/link";
@@ -136,7 +136,7 @@ export default function Header() {
<div className="-my-2 -mr-2 md:hidden">
<Popover.Button className="inline-flex items-center justify-center rounded-md bg-slate-100 p-2 text-slate-400 hover:bg-slate-100 hover:text-slate-500 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-teal-500 dark:bg-slate-700 dark:text-slate-200">
<span className="sr-only">Open menu</span>
<MenuIcon className="h-6 w-6" aria-hidden="true" />
<Bars3Icon className="h-6 w-6" aria-hidden="true" />
</Popover.Button>
</div>
<Popover.Group as="nav" className="hidden space-x-6 md:flex lg:space-x-10">
@@ -356,7 +356,7 @@ export default function Header() {
<div className="-mr-2">
<Popover.Button className="inline-flex items-center justify-center rounded-md bg-white p-2 text-slate-400 hover:bg-slate-100 hover:text-slate-500 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-teal-500 dark:bg-slate-700 dark:text-slate-200">
<span className="sr-only">Close menu</span>
<XIcon className="h-6 w-6" aria-hidden="true" />
<XMarkIcon className="h-6 w-6" aria-hidden="true" />
</Popover.Button>
</div>
</div>

View File

@@ -1,4 +1,4 @@
import { CopyIcon } from "lucide-react";
import { DocumentDuplicateIcon } from "@heroicons/react/24/outline";
import { useRouter } from "next/router";
import { Button } from "@formbricks/ui/Button";
@@ -28,7 +28,7 @@ export default function HeadingCentered() {
<div className="flex h-20 w-full items-center justify-between rounded-lg bg-slate-300 px-8 text-slate-700 dark:bg-slate-800 dark:text-slate-200 ">
<p>npm install @formbricks/react</p>
<button onClick={() => navigator.clipboard.writeText("npm install @formbricks/react")}>
<CopyIcon className="h-8 w-8" />
<DocumentDuplicateIcon className="h-8 w-8" />
</button>
</div>
</div>

View File

@@ -1,4 +1,4 @@
import { CheckIcon, XIcon } from "lucide-react";
import { CheckIcon, XMarkIcon } from "@heroicons/react/24/outline";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@formbricks/ui/Tooltip";
@@ -54,9 +54,13 @@ export const PricingTable = ({ leadRow, pricing, endRow }) => {
</Tooltip>
</TooltipProvider>
) : feature.free ? (
<CheckIcon className=" rounded-full border border-green-300 bg-green-100 p-0.5 text-green-500 dark:border-green-600 dark:bg-green-900 dark:text-green-300" />
<div className="h-6 w-6 rounded-full border border-green-300 bg-green-100 p-0.5 dark:border-green-600 dark:bg-green-900">
<CheckIcon className=" text-green-500 dark:text-green-300" />
</div>
) : (
<XIcon className="rounded-full border border-red-300 bg-red-100 p-0.5 text-red-500 dark:border-red-500 dark:bg-red-300 dark:text-red-600" />
<div className="h-6 w-6 rounded-full border border-red-300 bg-red-100 p-0.5 dark:border-red-500 dark:bg-red-300">
<XMarkIcon className="text-red-500 dark:text-red-600" />
</div>
)}
</div>
<div className="flex w-1/3 items-center justify-center text-center text-sm text-slate-800 dark:text-slate-100">
@@ -74,9 +78,13 @@ export const PricingTable = ({ leadRow, pricing, endRow }) => {
</Tooltip>
</TooltipProvider>
) : feature.paid ? (
<CheckIcon className=" rounded-full border border-green-300 bg-green-100 p-0.5 text-green-500 dark:border-green-600 dark:bg-green-900 dark:text-green-300" />
<div className="h-6 w-6 rounded-full border border-green-300 bg-green-100 p-0.5 dark:border-green-600 dark:bg-green-900">
<CheckIcon className="text-green-500 dark:text-green-300" />
</div>
) : (
<XIcon className="rounded-full border border-red-300 bg-red-100 p-0.5 text-red-500 dark:border-red-500 dark:bg-red-300 dark:text-red-600" />
<div className="h-6 w-6 rounded-full border border-red-300 bg-red-100 p-0.5 dark:border-red-600 dark:bg-red-900">
<XMarkIcon className="text-red-500 dark:text-red-600" />
</div>
)}
</div>
</div>

View File

@@ -1,5 +1,5 @@
import LFGLuigi from "@/images/blog/lfg-luigi-200px.webp";
import { XIcon } from "lucide-react";
import { XMarkIcon } from "@heroicons/react/24/solid";
import Image from "next/image";
import React, { useEffect, useState } from "react";
@@ -70,7 +70,7 @@ const SlideInBanner: React.FC<Props> = ({ delay = 5000, scrollPercentage = 10, U
setTimeout(() => setIsDismissed(true), 500);
}}
className="rounded-full p-2 hover:bg-slate-600 hover:bg-opacity-30">
<XIcon className="h-6 w-6" />
<XMarkIcon className="h-6 w-6" />
</button>
</div>
</div>

View File

@@ -1,4 +1,4 @@
import { CopyIcon } from "lucide-react";
import { DocumentDuplicateIcon } from "@heroicons/react/24/outline";
import { useRouter } from "next/router";
import { Button } from "@formbricks/ui/Button";
@@ -30,7 +30,7 @@ export default function HeadingCentered() {
<div className="flex h-20 w-full items-center justify-between rounded-lg bg-slate-800 px-8 text-slate-100 ">
<p>npm install @formbricks/react</p>
<button onClick={() => navigator.clipboard.writeText("npm install @formbricks/react")}>
<CopyIcon className="h-8 w-8" />
<DocumentDuplicateIcon className="h-8 w-8" />
</button>
</div>
</div>

View File

@@ -1,35 +1,42 @@
import { BlocksIcon, BoxIcon, LockIcon, SwatchBookIcon, TerminalIcon, UsersIcon } from "lucide-react";
import {
CommandLineIcon,
CubeTransparentIcon,
SquaresPlusIcon,
SwatchIcon,
UserGroupIcon,
UsersIcon,
} from "@heroicons/react/24/outline";
const features = [
{
name: "Futureproof",
description: "Form needs change. With Formbricks youll avoid island solutions right from the start.",
icon: BoxIcon,
icon: CubeTransparentIcon,
},
{
name: "Privacy by design",
description: "Self-host the entire product and fly through privacy compliance reviews.",
icon: LockIcon,
icon: UsersIcon,
},
{
name: "Community driven",
description: "We're building for you. If you need something specific, were happy to build it!",
icon: UsersIcon,
icon: UserGroupIcon,
},
{
name: "Great DX",
description: "We love a solid developer experience. We felt your pain and do our best to avoid it.",
icon: TerminalIcon,
icon: CommandLineIcon,
},
{
name: "Customizable",
description: "We have to build opinionated. If it doesn't suit your need, just change it up.",
icon: SwatchBookIcon,
icon: SwatchIcon,
},
{
name: "Extendable",
description: "Even though we try, we cannot build every single integration. With Formbricks, you can.",
icon: BlocksIcon,
icon: SquaresPlusIcon,
},
];

View File

@@ -1,51 +1,50 @@
import { slugifyWithCounter } from '@sindresorhus/slugify'
import glob from 'fast-glob'
import * as fs from 'fs'
import { toString } from 'mdast-util-to-string'
import * as path from 'path'
import { remark } from 'remark'
import remarkMdx from 'remark-mdx'
import { createLoader } from 'simple-functional-loader'
import { filter } from 'unist-util-filter'
import { SKIP, visit } from 'unist-util-visit'
import * as url from 'url'
import { slugifyWithCounter } from "@sindresorhus/slugify";
import glob from "fast-glob";
import * as fs from "fs";
import { toString } from "mdast-util-to-string";
import * as path from "path";
import { remark } from "remark";
import remarkMdx from "remark-mdx";
import { createLoader } from "simple-functional-loader";
import { filter } from "unist-util-filter";
import { SKIP, visit } from "unist-util-visit";
import * as url from "url";
const __filename = url.fileURLToPath(import.meta.url)
const processor = remark().use(remarkMdx).use(extractSections)
const slugify = slugifyWithCounter()
const __filename = url.fileURLToPath(import.meta.url);
const processor = remark().use(remarkMdx).use(extractSections);
const slugify = slugifyWithCounter();
function isObjectExpression(node) {
return (
node.type === 'mdxTextExpression' &&
node.data?.estree?.body?.[0]?.expression?.type === 'ObjectExpression'
)
node.type === "mdxTextExpression" && node.data?.estree?.body?.[0]?.expression?.type === "ObjectExpression"
);
}
function excludeObjectExpressions(tree) {
return filter(tree, (node) => !isObjectExpression(node))
return filter(tree, (node) => !isObjectExpression(node));
}
function extractSections() {
return (tree, { sections }) => {
slugify.reset()
slugify.reset();
visit(tree, (node) => {
if (node.type === 'heading' || node.type === 'paragraph') {
let content = toString(excludeObjectExpressions(node))
if (node.type === 'heading' && node.depth <= 2) {
let hash = node.depth === 1 ? null : slugify(content)
sections.push([content, hash, []])
if (node.type === "heading" || node.type === "paragraph") {
let content = toString(excludeObjectExpressions(node));
if (node.type === "heading" && node.depth <= 2) {
let hash = node.depth === 1 ? null : slugify(content);
sections.push([content, hash, []]);
} else {
sections.at(-1)?.[2].push(content)
sections.at(-1)?.[2].push(content);
}
return SKIP
return SKIP;
}
})
}
});
};
}
export default function Search(nextConfig = {}) {
let cache = new Map()
export default function (nextConfig = {}) {
let cache = new Map();
return Object.assign({}, nextConfig, {
webpack(config, options) {
@@ -53,26 +52,26 @@ export default function Search(nextConfig = {}) {
test: __filename,
use: [
createLoader(function () {
let appDir = path.resolve('./src/app')
this.addContextDependency(appDir)
let appDir = path.resolve("./app");
this.addContextDependency(appDir);
let files = glob.sync('**/*.mdx', { cwd: appDir })
let files = glob.sync("**/*.mdx", { cwd: appDir });
let data = files.map((file) => {
let url = '/' + file.replace(/(^|\/)page\.mdx$/, '')
let mdx = fs.readFileSync(path.join(appDir, file), 'utf8')
let url = "/" + file.replace(/(^|\/)page\.mdx$/, "");
let mdx = fs.readFileSync(path.join(appDir, file), "utf8");
let sections = []
let sections = [];
if (cache.get(file)?.[0] === mdx) {
sections = cache.get(file)[1]
sections = cache.get(file)[1];
} else {
let vfile = { value: mdx, sections }
processor.runSync(processor.parse(vfile), vfile)
cache.set(file, [mdx, sections])
let vfile = { value: mdx, sections };
processor.runSync(processor.parse(vfile), vfile);
cache.set(file, [mdx, sections]);
}
return { url, sections }
})
return { url, sections };
});
// When this file is imported within the application
// the following module is loaded:
@@ -120,16 +119,16 @@ export default function Search(nextConfig = {}) {
pageTitle: item.doc.pageTitle,
}))
}
`
`;
}),
],
})
});
if (typeof nextConfig.webpack === 'function') {
return nextConfig.webpack(config, options)
if (typeof nextConfig.webpack === "function") {
return nextConfig.webpack(config, options);
}
return config
return config;
},
})
});
}

View File

@@ -12,60 +12,62 @@
},
"browserslist": "defaults, not ie <= 11",
"dependencies": {
"@algolia/autocomplete-core": "^1.17.0",
"@calcom/embed-react": "^1.3.2",
"@docsearch/react": "^3.6.0",
"@algolia/autocomplete-core": "^1.13.0",
"@calcom/embed-react": "^1.3.0",
"@docsearch/react": "^3.5.2",
"@formbricks/lib": "workspace:*",
"@formbricks/types": "workspace:*",
"@formbricks/ui": "workspace:*",
"@headlessui/react": "^1.7.18",
"@headlessui/react": "^1.7.17",
"@headlessui/tailwindcss": "^0.2.0",
"lucide-react": "^0.356.0",
"@heroicons/react": "^2.1.1",
"@mapbox/rehype-prism": "^0.9.0",
"@mdx-js/loader": "^3.0.1",
"@mdx-js/react": "^3.0.1",
"@next/mdx": "14.1.3",
"@mdx-js/loader": "^3.0.0",
"@mdx-js/react": "^3.0.0",
"@next/mdx": "14.0.4",
"@paralleldrive/cuid2": "^2.2.2",
"@radix-ui/react-slider": "^1.1.2",
"@radix-ui/react-tooltip": "^1.0.6",
"@sindresorhus/slugify": "^2.2.1",
"@tailwindcss/typography": "^0.5.10",
"acorn": "^8.11.3",
"autoprefixer": "^10.4.18",
"clsx": "^2.1.0",
"fast-glob": "^3.3.2",
"flexsearch": "^0.7.43",
"framer-motion": "11.0.13",
"@types/dompurify": "^3.0.5",
"@types/react-highlight-words": "^0.16.5",
"acorn": "^8.10.0",
"autoprefixer": "^10.4.15",
"clsx": "^2.0.0",
"fast-glob": "^3.3.1",
"flexsearch": "^0.7.31",
"framer-motion": "10.17.8",
"lottie-web": "^5.12.2",
"mdast-util-to-string": "^4.0.0",
"mdx-annotations": "^0.1.4",
"next": "14.1.3",
"next": "13.4.19",
"next-plausible": "^3.12.0",
"next-seo": "^6.5.0",
"next-seo": "^6.4.0",
"next-sitemap": "^4.2.3",
"next-themes": "^0.3.0",
"next-themes": "^0.2.1",
"node-fetch": "^3.3.2",
"prism-react-renderer": "^2.3.1",
"prismjs": "^1.29.0",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-highlight-words": "^0.20.0",
"react-icons": "^5.0.1",
"react-icons": "^4.12.0",
"react-markdown": "^9.0.1",
"react-responsive-embed": "^2.1.0",
"remark": "^15.0.1",
"remark-gfm": "^4.0.0",
"remark-mdx": "^3.0.1",
"sharp": "^0.33.2",
"remark-mdx": "^3.0.0",
"sharp": "^0.33.1",
"shiki": "^0.14.7",
"simple-functional-loader": "^1.2.1",
"tailwindcss": "^3.4.1",
"tailwindcss": "^3.4.0",
"unist-util-filter": "^5.0.1",
"unist-util-visit": "^5.0.0",
"zustand": "^4.5.2"
"zustand": "^4.4.7"
},
"devDependencies": {
"@formbricks/tsconfig": "workspace:*",
"@types/dompurify": "^3.0.5",
"@types/react-highlight-words": "^0.16.7",
"eslint-config-formbricks": "workspace:*"
}
}

View File

@@ -1,5 +1,5 @@
import footerLogoDark from "@/images/logo/footerlogo-dark.svg";
import { MenuIcon } from "lucide-react";
import { Bars3Icon } from "@heroicons/react/24/solid";
import Image from "next/image";
import Link from "next/link";
import { useState } from "react";
@@ -50,7 +50,7 @@ export default function HeaderLight() {
<Popover open={mobileNavMenuOpen} onOpenChange={setMobileNavMenuOpen}>
<PopoverTrigger onClick={() => setMobileNavMenuOpen(!mobileNavMenuOpen)}>
<span>
<MenuIcon className="h-8 w-8 rounded-md bg-slate-700 p-1 text-slate-200" />
<Bars3Icon className="h-8 w-8 rounded-md bg-slate-700 p-1 text-slate-200" />
</span>
</PopoverTrigger>
<PopoverContent className="border-slate-600 bg-slate-700 shadow">

View File

@@ -1,4 +1,4 @@
import { ChevronDownIcon } from "lucide-react";
import { ChevronDownIcon } from "@heroicons/react/24/outline";
import Image from "next/image";
import Link from "next/link";
import { FaGithub } from "react-icons/fa6";

View File

@@ -1,17 +1,16 @@
{
"extends": "@formbricks/tsconfig/nextjs.json",
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "../../packages/types/*.d.ts"],
"exclude": ["../../.env"],
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./*"]
},
"plugins": [
{
"name": "next"
}
],
"strictNullChecks": true
}
},
"plugins": [
{
"name": "next"
}
]
}

View File

@@ -17,19 +17,19 @@
},
"devDependencies": {
"@formbricks/tsconfig": "workspace:*",
"@storybook/addon-essentials": "^8.0.0",
"@storybook/addon-interactions": "^8.0.0",
"@storybook/addon-links": "^8.0.0",
"@storybook/addon-onboarding": "^8.0.0",
"@storybook/blocks": "^8.0.0",
"@storybook/react": "^8.0.0",
"@storybook/react-vite": "^8.0.0",
"@storybook/addon-essentials": "^7.6.7",
"@storybook/addon-interactions": "^7.6.7",
"@storybook/addon-links": "^7.6.7",
"@storybook/addon-onboarding": "^1.0.10",
"@storybook/blocks": "^7.6.7",
"@storybook/react": "^7.6.7",
"@storybook/react-vite": "^7.6.7",
"@storybook/testing-library": "^0.2.2",
"@typescript-eslint/eslint-plugin": "^7.2.0",
"@typescript-eslint/parser": "^7.2.0",
"@typescript-eslint/eslint-plugin": "^6.18.1",
"@typescript-eslint/parser": "^6.18.1",
"@vitejs/plugin-react": "^4.2.1",
"esbuild": "^0.20.1",
"tsup": "^8.0.2",
"vite": "^5.1.6"
"esbuild": "^0.19.11",
"tsup": "^8.0.1",
"vite": "^5.0.12"
}
}

3
apps/web/.gitignore vendored
View File

@@ -42,5 +42,4 @@ next-env.d.ts
token.json
# Local Uploads
uploads/
certificates
uploads/

View File

@@ -23,11 +23,21 @@ RUN corepack enable && corepack prepare pnpm@latest --activate
# Install necessary build tools and compilers
RUN apk update && apk add --no-cache g++ cmake make gcc python3 openssl-dev jq
# Install Supercronic (cron for containers without super user privileges)
RUN apk add --no-cache curl \
&& curl -fsSLo /tmp/supercronic \
"https://github.com/aptible/supercronic/releases/download/v0.2.27/supercronic-linux-amd64" \
&& chmod +x /tmp/supercronic
# Set hardcoded environment variables
ENV DATABASE_URL="postgresql://placeholder:for@build:5432/gets_overwritten_at_runtime?schema=public"
ENV NEXTAUTH_SECRET="placeholder_for_next_auth_of_64_chars_get_overwritten_at_runtime"
ENV ENCRYPTION_KEY="placeholder_for_build_key_of_64_chars_get_overwritten_at_runtime"
# Set environment variables
ARG DATABASE_URL
ENV DATABASE_URL=$DATABASE_URL
ARG NEXTAUTH_SECRET
ENV NEXTAUTH_SECRET=$NEXTAUTH_SECRET
ARG ENCRYPTION_KEY
ENV ENCRYPTION_KEY=$ENCRYPTION_KEY
ARG NEXT_PUBLIC_SENTRY_DSN
@@ -61,12 +71,12 @@ FROM base AS runner
RUN corepack enable && corepack prepare pnpm@latest --activate
RUN apk add --no-cache curl \
&& apk add --no-cache supercronic \
# && addgroup --system --gid 1001 nodejs \
&& adduser --system --uid 1001 nextjs
WORKDIR /home/nextjs
COPY --from=installer /tmp/supercronic /usr/local/bin/supercronic
COPY --from=installer /app/apps/web/next.config.mjs .
COPY --from=installer /app/apps/web/package.json .
# Leverage output traces to reduce image size
@@ -74,14 +84,10 @@ COPY --from=installer --chown=nextjs:nextjs /app/apps/web/.next/standalone ./
COPY --from=installer --chown=nextjs:nextjs /app/apps/web/.next/static ./apps/web/.next/static
COPY --from=installer --chown=nextjs:nextjs /app/apps/web/public ./apps/web/public
COPY --from=installer --chown=nextjs:nextjs /app/packages/database/schema.prisma ./packages/database/schema.prisma
COPY --from=installer --chown=nextjs:nextjs /app/packages/database/package.json ./packages/database/package.json
COPY --from=installer --chown=nextjs:nextjs /app/packages/database/migrations ./packages/database/migrations
COPY --from=installer --chown=nextjs:nextjs /prisma_version.txt .
COPY /docker/cronjobs /app/docker/cronjobs
# Install Prisma globally
RUN PRISMA_VERSION=$(cat prisma_version.txt) && npm install -g prisma@$PRISMA_VERSION
EXPOSE 3000
ENV HOSTNAME "0.0.0.0"
USER nextjs
@@ -90,6 +96,12 @@ USER nextjs
RUN mkdir -p /home/nextjs/apps/web/uploads/
VOLUME /home/nextjs/apps/web/uploads/
CMD supercronic -quiet /app/docker/cronjobs & \
(cd packages/database && pnpm db:migrate:deploy) && \
exec node apps/web/server.js
CMD PRISMA_VERSION=$(cat prisma_version.txt) && \
supercronic -quiet /app/docker/cronjobs & \
if [ "$NEXTAUTH_SECRET" != "RANDOM_STRING" ]; then \
pnpm dlx prisma@$PRISMA_VERSION migrate deploy && \
exec node apps/web/server.js; \
else \
echo "ERROR: Please set a value for NEXTAUTH_SECRET in your docker compose variables!" >&2; \
exit 1; \
fi

View File

@@ -1,6 +1,6 @@
"use client";
import { Code2Icon, MousePointerClickIcon, SparklesIcon } from "lucide-react";
import { CodeBracketIcon, CursorArrowRaysIcon, SparklesIcon } from "@heroicons/react/24/solid";
import { useEffect, useState } from "react";
import { capitalizeFirstLetter } from "@formbricks/lib/strings";
@@ -125,11 +125,11 @@ export default function EventActivityTab({ actionClass, environmentId }: Activit
<div className="mt-1 flex items-center">
<div className="mr-1.5 h-4 w-4 text-slate-600">
{actionClass.type === "code" ? (
<Code2Icon className="h-5 w-5" />
<CodeBracketIcon />
) : actionClass.type === "noCode" ? (
<MousePointerClickIcon className="h-5 w-5" />
<CursorArrowRaysIcon />
) : actionClass.type === "automatic" ? (
<SparklesIcon className="h-5 w-5" />
<SparklesIcon />
) : null}
</div>
<p className="text-sm text-slate-700 ">{capitalizeFirstLetter(actionClass.type)}</p>

View File

@@ -1,6 +1,6 @@
"use client";
import { MousePointerClickIcon } from "lucide-react";
import { CursorArrowRaysIcon } from "@heroicons/react/24/solid";
import { useState } from "react";
import { useMembershipRole } from "@formbricks/lib/membership/hooks/useMembershipRole";
@@ -53,7 +53,7 @@ export default function ActionClassesTable({
onClick={() => {
setAddActionModalOpen(true);
}}>
<MousePointerClickIcon className="mr-2 h-5 w-5 text-white" />
<CursorArrowRaysIcon className="mr-2 h-5 w-5 text-white" />
Add Action
</Button>
</div>

View File

@@ -1,4 +1,4 @@
import { Code2Icon, MousePointerClickIcon, SparklesIcon } from "lucide-react";
import { CodeBracketIcon, CursorArrowRaysIcon, SparklesIcon } from "@heroicons/react/24/solid";
import { TActionClass } from "@formbricks/types/actionClasses";
import { TMembershipRole } from "@formbricks/types/memberships";
@@ -48,11 +48,11 @@ export default function ActionDetailModal({
tabs={tabs}
icon={
actionClass.type === "code" ? (
<Code2Icon className="h-5 w-5" />
<CodeBracketIcon />
) : actionClass.type === "noCode" ? (
<MousePointerClickIcon className="h-5 w-5" />
<CursorArrowRaysIcon />
) : actionClass.type === "automatic" ? (
<SparklesIcon className="h-5 w-5" />
<SparklesIcon />
) : null
}
label={actionClass.name}

View File

@@ -1,4 +1,4 @@
import { Code2Icon, MousePointerClickIcon, SparklesIcon } from "lucide-react";
import { CodeBracketIcon, CursorArrowRaysIcon, SparklesIcon } from "@heroicons/react/24/solid";
import { timeSinceConditionally } from "@formbricks/lib/time";
import { TActionClass } from "@formbricks/types/actionClasses";
@@ -10,11 +10,11 @@ export default function ActionClassDataRow({ actionClass }: { actionClass: TActi
<div className="flex items-center">
<div className="h-5 w-5 flex-shrink-0 text-slate-500">
{actionClass.type === "code" ? (
<Code2Icon className="h-5 w-5" />
<CodeBracketIcon />
) : actionClass.type === "noCode" ? (
<MousePointerClickIcon className="h-5 w-5" />
<CursorArrowRaysIcon />
) : actionClass.type === "automatic" ? (
<SparklesIcon className="h-5 w-5" />
<SparklesIcon />
) : null}
</div>
<div className="ml-4 text-left">

View File

@@ -4,7 +4,7 @@ import {
deleteActionClassAction,
updateActionClassAction,
} from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/actions";
import { TrashIcon } from "lucide-react";
import { TrashIcon } from "@heroicons/react/24/outline";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useForm } from "react-hook-form";

View File

@@ -1,7 +1,7 @@
"use client";
import { createActionClassAction } from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/actions";
import { MousePointerClickIcon } from "lucide-react";
import { CursorArrowRaysIcon } from "@heroicons/react/24/solid";
import { Terminal } from "lucide-react";
import { useState } from "react";
import { useForm } from "react-hook-form";
@@ -155,7 +155,7 @@ export default function AddNoCodeActionModal({
<div className="flex w-full items-center justify-between p-6">
<div className="flex items-center space-x-2">
<div className="mr-1.5 h-6 w-6 text-slate-500">
<MousePointerClickIcon className="h-5 w-5" />
<CursorArrowRaysIcon />
</div>
<div>
<div className="text-xl font-medium text-slate-700">Track New User Action</div>
@@ -224,7 +224,7 @@ export default function AddNoCodeActionModal({
Cancel
</Button>
<Button variant="darkCTA" type="submit" loading={isCreatingAction}>
Create Action
Track Action
</Button>
</div>
</div>
@@ -275,7 +275,7 @@ export default function AddNoCodeActionModal({
Cancel
</Button>
<Button variant="darkCTA" type="submit" loading={isCreatingAction}>
Create Action
Track Action
</Button>
</div>
</div>

View File

@@ -1,4 +1,4 @@
import { MousePointerClickIcon } from "lucide-react";
import { CursorArrowRaysIcon } from "@heroicons/react/24/solid";
import { Button } from "@formbricks/ui/Button";
@@ -9,7 +9,7 @@ export default function Loading() {
<Button
variant="darkCTA"
className="pointer-events-none animate-pulse cursor-not-allowed select-none bg-slate-200">
<MousePointerClickIcon className="mr-2 h-5 w-5 text-white" />
<CursorArrowRaysIcon className="mr-2 h-5 w-5 text-white" />
Loading
</Button>
</div>

View File

@@ -1,5 +1,5 @@
import SecondNavbar from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/attributes/components/SecondNavbar";
import { MousePointerClickIcon, TagIcon } from "lucide-react";
import { CursorArrowRaysIcon, TagIcon } from "@heroicons/react/24/solid";
interface ActionsAttributesTabsProps {
activeId: string;
@@ -11,13 +11,13 @@ export default function ActionsAttributesTabs({ activeId, environmentId }: Actio
{
id: "actions",
label: "Actions",
icon: <MousePointerClickIcon className="h-5 w-5" />,
icon: <CursorArrowRaysIcon />,
href: `/environments/${environmentId}/actions`,
},
{
id: "attributes",
label: "Attributes",
icon: <TagIcon className="h-5 w-5" />,
icon: <TagIcon />,
href: `/environments/${environmentId}/attributes`,
},
];

View File

@@ -1,7 +1,7 @@
"use client";
import { getSegmentsByAttributeClassAction } from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/attributes/actions";
import { TagIcon } from "lucide-react";
import { TagIcon } from "@heroicons/react/24/solid";
import { useEffect, useState } from "react";
import { capitalizeFirstLetter } from "@formbricks/lib/strings";
@@ -86,7 +86,7 @@ export default function AttributeActivityTab({ attributeClass }: EventActivityTa
<Label className="block text-xs font-normal text-slate-500">Type</Label>
<div className="mt-1 flex items-center">
<div className="mr-1.5 h-4 w-4 text-slate-600">
<TagIcon className="h-4 w-4" />
<TagIcon />
</div>
<p className="text-sm text-slate-700 ">{capitalizeFirstLetter(attributeClass.type)}</p>
</div>

View File

@@ -1,4 +1,4 @@
import { TagIcon } from "lucide-react";
import { TagIcon } from "@heroicons/react/24/solid";
import { TAttributeClass } from "@formbricks/types/attributeClasses";
import ModalWithTabs from "@formbricks/ui/ModalWithTabs";
@@ -30,7 +30,7 @@ export default function AttributeDetailModal({ open, setOpen, attributeClass }:
open={open}
setOpen={setOpen}
tabs={tabs}
icon={<TagIcon className="h-5 w-5" />}
icon={<TagIcon />}
label={attributeClass.name}
description={attributeClass.description || ""}
/>

View File

@@ -1,4 +1,4 @@
import { TagIcon } from "lucide-react";
import { TagIcon } from "@heroicons/react/24/solid";
import { timeSinceConditionally } from "@formbricks/lib/time";
import { Badge } from "@formbricks/ui/Badge";

View File

@@ -1,7 +1,7 @@
"use client";
import { ArchiveBoxArrowDownIcon, ArchiveBoxXMarkIcon } from "@heroicons/react/24/solid";
import type { AttributeClass } from "@prisma/client";
import { ArchiveIcon, ArchiveXIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useForm } from "react-hook-form";
@@ -86,13 +86,13 @@ export default function AttributeSettingsTab({ attributeClass, setOpen }: Attrib
{attributeClass.archived ? (
<>
{" "}
<ArchiveXIcon className="mr-2 h-4 text-slate-600" />
<ArchiveBoxXMarkIcon className="mr-2 h-4 text-slate-600" />
<span>Unarchive</span>
</>
) : (
<>
{" "}
<ArchiveIcon className="mr-2 h-4 text-slate-600" />
<ArchiveBoxArrowDownIcon className="mr-2 h-4 text-slate-600" />
<span>Archive</span>
</>
)}

View File

@@ -1,4 +1,4 @@
import { HelpCircleIcon } from "lucide-react";
import { QuestionMarkCircleIcon } from "@heroicons/react/24/solid";
import { Button } from "@formbricks/ui/Button";
@@ -8,7 +8,7 @@ export default function HowToAddAttributesButton() {
variant="secondary"
href="https://formbricks.com/docs/attributes/custom-attributes"
target="_blank">
<HelpCircleIcon className="mr-2 h-4 w-4" />
<QuestionMarkCircleIcon className="mr-2 h-4 w-4" />
How to add attributes
</Button>
);

View File

@@ -1,4 +1,4 @@
import { HelpCircleIcon, TagIcon } from "lucide-react";
import { QuestionMarkCircleIcon, TagIcon } from "@heroicons/react/24/solid";
import { Button } from "@formbricks/ui/Button";
@@ -10,7 +10,7 @@ export default function Loading() {
<Button
variant="secondary"
className="pointer-events-none animate-pulse cursor-not-allowed select-none">
<HelpCircleIcon className="mr-2 h-4 w-4" />
<QuestionMarkCircleIcon className="mr-2 h-4 w-4" />
Loading Attributes
</Button>
</div>

View File

@@ -1,5 +1,5 @@
import SecondNavbar from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/attributes/components/SecondNavbar";
import { UserIcon, UsersIcon } from "lucide-react";
import { UserGroupIcon, UserIcon } from "@heroicons/react/24/solid";
interface PeopleSegmentsTabsProps {
activeId: string;
@@ -12,13 +12,13 @@ export default function PeopleSegmentsTabs({ activeId, environmentId }: PeopleSe
{
id: "people",
label: "People",
icon: <UserIcon className="h-5 w-5" />,
icon: <UserIcon />,
href: `/environments/${environmentId}/people`,
},
{
id: "segments",
label: "Segments",
icon: <UsersIcon className="h-5 w-5" />,
icon: <UserGroupIcon />,
href: `/environments/${environmentId}/segments`,
},
];

View File

@@ -1,5 +1,5 @@
import { CodeBracketIcon, CursorArrowRaysIcon, SparklesIcon } from "@heroicons/react/24/solid";
import { formatDistance } from "date-fns";
import { CodeIcon, MousePointerClickIcon, SparklesIcon } from "lucide-react";
import { TAction } from "@formbricks/types/actions";
import { Label } from "@formbricks/ui/Label";
@@ -8,9 +8,9 @@ import { Popover, PopoverContent, PopoverTrigger } from "@formbricks/ui/Popover"
export const ActivityItemIcon = ({ actionItem }: { actionItem: TAction }) => (
<div className="h-12 w-12 rounded-full bg-white p-3 text-slate-500 duration-100 ease-in-out group-hover:scale-110 group-hover:text-slate-600">
<div>
{actionItem.actionClass?.type === "code" && <CodeIcon className="h-5 w-5" />}
{actionItem.actionClass?.type === "noCode" && <MousePointerClickIcon className="h-5 w-5" />}
{actionItem.actionClass?.type === "automatic" && <SparklesIcon className="h-5 w-5" />}
{actionItem.actionClass?.type === "code" && <CodeBracketIcon />}
{actionItem.actionClass?.type === "noCode" && <CursorArrowRaysIcon />}
{actionItem.actionClass?.type === "automatic" && <SparklesIcon />}
</div>
</div>
);

View File

@@ -1,7 +1,7 @@
"use client";
import ResponseFeed from "@/app/(app)/environments/[environmentId]/(peopleAndSegments)/people/[personId]/components/ResponsesFeed";
import { ArrowDownUpIcon } from "lucide-react";
import { ArrowsUpDownIcon } from "@heroicons/react/24/outline";
import { useEffect, useState } from "react";
import { TEnvironment } from "@formbricks/types/environment";
@@ -41,7 +41,7 @@ export default function ResponseTimeline({
type="button"
onClick={toggleSortResponses}
className="hover:text-brand-dark flex items-center px-1 text-slate-800">
<ArrowDownUpIcon className="inline h-4 w-4" />
<ArrowsUpDownIcon className="inline h-4 w-4" />
</button>
</div>
</div>

View File

@@ -2,8 +2,6 @@
import { useEffect, useState } from "react";
import { useMembershipRole } from "@formbricks/lib/membership/hooks/useMembershipRole";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { TEnvironment } from "@formbricks/types/environment";
import { TResponse } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys";
@@ -46,62 +44,28 @@ export default function ResponseFeed({
{fetchedResponses.length === 0 ? (
<EmptySpaceFiller type="response" environment={environment} />
) : (
fetchedResponses.map((response) => (
<ResponseSurveyCard
key={response.id}
response={response}
surveys={surveys}
user={user}
environmentTags={environmentTags}
environment={environment}
deleteResponse={deleteResponse}
updateResponse={updateResponse}
/>
))
fetchedResponses.map((response) => {
const survey = surveys.find((survey) => {
return survey.id === response.surveyId;
});
return (
<div key={response.id}>
{survey && (
<SingleResponseCard
response={response}
survey={survey}
user={user}
pageType="people"
environmentTags={environmentTags}
environment={environment}
deleteResponse={deleteResponse}
updateResponse={updateResponse}
/>
)}
</div>
);
})
)}
</>
);
}
const ResponseSurveyCard = ({
response,
surveys,
user,
environmentTags,
environment,
deleteResponse,
updateResponse,
}: {
response: TResponse;
surveys: TSurvey[];
user: TUser;
environmentTags: TTag[];
environment: TEnvironment;
deleteResponse: (responseId: string) => void;
updateResponse: (responseId: string, response: TResponse) => void;
}) => {
const survey = surveys.find((survey) => {
return survey.id === response.surveyId;
});
const { membershipRole } = useMembershipRole(survey?.environmentId || "");
const { isViewer } = getAccessFlags(membershipRole);
return (
<div key={response.id}>
{survey && (
<SingleResponseCard
response={response}
survey={survey}
user={user}
pageType="people"
environmentTags={environmentTags}
environment={environment}
deleteResponse={deleteResponse}
updateResponse={updateResponse}
isViewer={isViewer}
/>
)}
</div>
);
};

View File

@@ -2,7 +2,7 @@ import {
ActivityItemIcon,
ActivityItemPopover,
} from "@/app/(app)/environments/[environmentId]/(peopleAndSegments)/people/[personId]/components/ActivityItemComponents";
import { ArrowDownUpIcon } from "lucide-react";
import { ArrowsUpDownIcon } from "@heroicons/react/24/outline";
import { TrashIcon } from "lucide-react";
import { TAction } from "@formbricks/types/actions";
@@ -100,7 +100,7 @@ export default function Loading() {
<h2 className="text-lg font-bold text-slate-700">Responses</h2>
<div className="text-right">
<button className="hover:text-brand-dark pointer-events-none flex animate-pulse cursor-not-allowed select-none items-center px-1 text-slate-800">
<ArrowDownUpIcon className="inline h-4 w-4" />
<ArrowsUpDownIcon className="inline h-4 w-4" />
</button>
</div>
</div>

View File

@@ -1,6 +1,6 @@
"use client";
import { UsersIcon } from "lucide-react";
import { UserGroupIcon } from "@heroicons/react/20/solid";
import { FilterIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useMemo, useState } from "react";
@@ -136,7 +136,7 @@ const BasicCreateSegmentModal = ({
<div className="flex w-full items-center gap-4 p-6">
<div className="flex items-center space-x-2">
<div className="mr-1.5 h-6 w-6 text-slate-500">
<UsersIcon className="h-5 w-5" />
<UserGroupIcon />
</div>
<div>
<h3 className="text-base font-medium">Create Segment</h3>

View File

@@ -1,6 +1,6 @@
"use client";
import { UsersIcon } from "lucide-react";
import { UserGroupIcon } from "@heroicons/react/24/solid";
import SegmentSettings from "@formbricks/ee/advancedTargeting/components/SegmentSettings";
import { TActionClass } from "@formbricks/types/actionClasses";
@@ -76,7 +76,7 @@ export default function EditSegmentModal({
open={open}
setOpen={setOpen}
tabs={tabs}
icon={<UsersIcon className="h-5 w-5" />}
icon={<UserGroupIcon />}
label={currentSegment.title}
description={currentSegment.description || ""}
closeOnOutsideClick={false}

View File

@@ -1,7 +1,7 @@
"use client";
import { UserGroupIcon } from "@heroicons/react/24/solid";
import { format, formatDistanceToNow } from "date-fns";
import { UsersIcon } from "lucide-react";
import { useState } from "react";
import { TActionClass } from "@formbricks/types/actionClasses";
@@ -39,7 +39,7 @@ const SegmentTableDataRow = ({
<div className="col-span-4 flex items-center pl-6 text-sm">
<div className="flex items-center gap-4">
<div className="ph-no-capture h-8 w-8 flex-shrink-0 text-slate-700">
<UsersIcon className="h-5 w-5" />
<UserGroupIcon />
</div>
<div className="flex flex-col">
<div className="ph-no-capture font-medium text-slate-900">{title}</div>

View File

@@ -1,7 +1,7 @@
"use client";
import { createProductAction } from "@/app/(app)/environments/[environmentId]/actions";
import { PlusCircleIcon } from "lucide-react";
import { PlusCircleIcon } from "@heroicons/react/24/outline";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useForm } from "react-hook-form";
@@ -51,7 +51,7 @@ export default function AddProductModal({ environmentId, open, setOpen }: AddPro
<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">
<PlusCircleIcon className="h-5 w-5" />
<PlusCircleIcon />
</div>
<div>
<div className="text-xl font-medium text-slate-700">Add Product</div>

View File

@@ -1,11 +1,11 @@
import { HelpCircleIcon } from "lucide-react";
import { QuestionMarkCircleIcon } from "@heroicons/react/24/solid";
import { Button } from "@formbricks/ui/Button";
export default function HowToAddPeopleButton() {
return (
<Button variant="secondary" href="https://formbricks.com/docs/attributes/identify-users" target="_blank">
<HelpCircleIcon className="mr-2 h-4 w-4" />
<QuestionMarkCircleIcon className="mr-2 h-4 w-4" />
How to add people
</Button>
);

View File

@@ -2,23 +2,23 @@
import FaveIcon from "@/app/favicon.ico";
import { formbricksLogout } from "@/app/lib/formbricks";
import clsx from "clsx";
import {
BrushIcon,
AdjustmentsVerticalIcon,
ArrowRightOnRectangleIcon,
ChatBubbleBottomCenterTextIcon,
ChevronDownIcon,
CodeIcon,
CodeBracketIcon,
CreditCardIcon,
FileCheckIcon,
DocumentCheckIcon,
EnvelopeIcon,
HeartIcon,
LinkIcon,
LogOutIcon,
MailIcon,
MessageSquareTextIcon,
PaintBrushIcon,
PlusIcon,
SlidersIcon,
UserCircleIcon,
UsersIcon,
} from "lucide-react";
} from "@heroicons/react/24/solid";
import clsx from "clsx";
import { MenuIcon } from "lucide-react";
import type { Session } from "next-auth";
import { signOut } from "next-auth/react";
@@ -155,13 +155,13 @@ export default function Navigation({
title: "Survey",
links: [
{
icon: SlidersIcon,
icon: AdjustmentsVerticalIcon,
label: "Product Settings",
href: `/environments/${environment.id}/settings/product`,
hidden: false,
},
{
icon: BrushIcon,
icon: PaintBrushIcon,
label: "Look & Feel",
href: `/environments/${environment.id}/settings/lookandfeel`,
hidden: isViewer,
@@ -189,7 +189,7 @@ export default function Navigation({
title: "Setup",
links: [
{
icon: FileCheckIcon,
icon: DocumentCheckIcon,
label: "Setup checklist",
href: `/environments/${environment.id}/settings/setup`,
hidden: widgetSetupCompleted,
@@ -203,7 +203,7 @@ export default function Navigation({
},
},
{
icon: CodeIcon,
icon: CodeBracketIcon,
label: "Developer Docs",
href: "https://formbricks.com/docs",
target: "_blank",
@@ -483,7 +483,7 @@ export default function Navigation({
<DropdownMenuItem>
<a href="mailto:johannes@formbricks.com">
<div className="flex items-center">
<MailIcon className="mr-2 h-4 w-4" />
<EnvelopeIcon className="mr-2 h-4 w-4" />
<span>Email us!</span>
</div>
</a>
@@ -494,7 +494,7 @@ export default function Navigation({
formbricks.track("Top Menu: Product Feedback");
}}>
<div className="flex items-center">
<MessageSquareTextIcon className="mr-2 h-4 w-4" />
<ChatBubbleBottomCenterTextIcon className="mr-2 h-4 w-4" />
<span>Product Feedback</span>
</div>
</button>
@@ -507,7 +507,7 @@ export default function Navigation({
await formbricksLogout();
}}>
<div className="flex h-full w-full items-center">
<LogOutIcon className="mr-2 h-4 w-4" />
<ArrowRightOnRectangleIcon className="mr-2 h-4 w-4" />
Logout
</div>
</DropdownMenuItem>

View File

@@ -1,4 +1,4 @@
import { LinkIcon } from "lucide-react";
import { LinkIcon } from "@heroicons/react/24/outline";
import { Modal } from "@formbricks/ui/Modal";
@@ -18,7 +18,7 @@ export default function UrlShortenerModal({ open, setOpen, webAppUrl }: UrlShort
<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" />
<LinkIcon />
</div>
<div>
<div className="text-xl font-medium text-slate-700">URL shortener</div>

View File

@@ -1,5 +1,5 @@
import { CheckIcon, ExclamationTriangleIcon } from "@heroicons/react/24/solid";
import clsx from "clsx";
import { AlertTriangleIcon, CheckIcon } from "lucide-react";
import Link from "next/link";
import { getEnvironment } from "@formbricks/lib/environment/service";
@@ -18,7 +18,7 @@ export default async function WidgetStatusIndicator({ environmentId, type }: Wid
const stati = {
notImplemented: {
icon: AlertTriangleIcon,
icon: ExclamationTriangleIcon,
title: "Connect Formbricks to your app or website.",
subtitle:
"Your app or website is not yet connected with Formbricks. To run in-app surveys follow the setup guide.",

View File

@@ -1,6 +1,6 @@
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions";
import { ChevronDownIcon } from "@heroicons/react/24/solid";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import { ChevronDownIcon } from "lucide-react";
import Image from "next/image";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";

View File

@@ -6,8 +6,8 @@ import {
} from "@/app/(app)/environments/[environmentId]/integrations/notion/constants";
import { questionTypes } from "@/app/lib/questions";
import NotionLogo from "@/images/notion.png";
import { ArrowPathIcon, ChevronDownIcon, PlusIcon, XMarkIcon } from "@heroicons/react/24/solid";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import { ChevronDownIcon, PlusIcon, RefreshCcwIcon, XIcon } from "lucide-react";
import Image from "next/image";
import React, { useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
@@ -402,7 +402,7 @@ export default function AddIntegrationModal({
mapping.length > 1 ? "visible" : "invisible"
}`}
onClick={deleteRow}>
<XIcon className="h-5 w-5 text-red-500" />
<XMarkIcon className="h-5 w-5 text-red-500" />
</button>
</div>
</div>
@@ -582,7 +582,7 @@ const DropdownSelector = ({
onClick={() => {
refetch();
}}>
<RefreshCcwIcon className="h-5 w-5 font-bold text-slate-500" />
<ArrowPathIcon className="h-5 w-5 font-bold text-slate-500" />
</button>
)}
</div>

View File

@@ -4,8 +4,8 @@ import { triggers } from "@/app/(app)/environments/[environmentId]/integrations/
import SurveyCheckboxGroup from "@/app/(app)/environments/[environmentId]/integrations/webhooks/components/SurveyCheckboxGroup";
import TriggerCheckboxGroup from "@/app/(app)/environments/[environmentId]/integrations/webhooks/components/TriggerCheckboxGroup";
import { testEndpoint } from "@/app/(app)/environments/[environmentId]/integrations/webhooks/components/testEndpoint";
import { TrashIcon } from "@heroicons/react/24/outline";
import clsx from "clsx";
import { TrashIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useForm } from "react-hook-form";

View File

@@ -1,6 +1,6 @@
"use client";
import { AlertTriangleIcon } from "lucide-react";
import { ExclamationTriangleIcon } from "@heroicons/react/24/solid";
import { useForm } from "react-hook-form";
import { Button } from "@formbricks/ui/Button";
@@ -46,7 +46,7 @@ export default function AddMemberModal({ open, setOpen, onSubmit }: MemberModalP
</div>
<div className="flex items-center rounded-lg border border-slate-200 bg-slate-100 p-2 text-sm text-slate-700">
<AlertTriangleIcon className="mx-3 h-12 w-12 text-amber-500" />
<ExclamationTriangleIcon className="mx-3 h-12 w-12 text-amber-500" />
<p>
For security reasons, the API key will only be <strong>shown once</strong> after creation.
Please copy it to your destination right away.

View File

@@ -1,6 +1,7 @@
"use client";
import { FilesIcon, TrashIcon } from "lucide-react";
import { TrashIcon } from "@heroicons/react/24/outline";
import { FilesIcon } from "lucide-react";
import { useState } from "react";
import toast from "react-hot-toast";

View File

@@ -1,21 +1,21 @@
"use client";
import clsx from "clsx";
import { ChevronDownIcon } from "@heroicons/react/20/solid";
import {
BellRingIcon,
AdjustmentsVerticalIcon,
BellAlertIcon,
BoltIcon,
BrushIcon,
ChevronDownIcon,
CreditCardIcon,
FileCheckIcon,
FileSearch2Icon,
HashIcon,
DocumentCheckIcon,
DocumentMagnifyingGlassIcon,
HashtagIcon,
KeyIcon,
LinkIcon,
SlidersIcon,
PaintBrushIcon,
UserCircleIcon,
UsersIcon,
} from "lucide-react";
} from "@heroicons/react/24/solid";
import clsx from "clsx";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useMemo, useState } from "react";
@@ -76,7 +76,7 @@ export default function SettingsNavbar({
{
name: "Notifications",
href: `/environments/${environmentId}/settings/notifications`,
icon: BellRingIcon,
icon: BellAlertIcon,
current: pathname?.includes("/notifications"),
hidden: false,
},
@@ -89,14 +89,14 @@ export default function SettingsNavbar({
{
name: "Settings",
href: `/environments/${environmentId}/settings/product`,
icon: SlidersIcon,
icon: AdjustmentsVerticalIcon,
current: pathname?.includes("/product"),
hidden: false,
},
{
name: "Look & Feel",
href: `/environments/${environmentId}/settings/lookandfeel`,
icon: BrushIcon,
icon: PaintBrushIcon,
current: pathname?.includes("/lookandfeel"),
hidden: isViewer,
},
@@ -110,7 +110,7 @@ export default function SettingsNavbar({
{
name: "Tags",
href: `/environments/${environmentId}/settings/tags`,
icon: HashIcon,
icon: HashtagIcon,
current: pathname?.includes("/tags"),
hidden: isViewer,
},
@@ -150,14 +150,14 @@ export default function SettingsNavbar({
{
name: "Setup Checklist",
href: `/environments/${environmentId}/settings/setup`,
icon: FileCheckIcon,
icon: DocumentCheckIcon,
current: pathname?.includes("/setup"),
hidden: false,
},
{
name: "Documentation",
href: "https://formbricks.com/docs",
icon: FileSearch2Icon,
icon: DocumentMagnifyingGlassIcon,
target: "_blank",
hidden: false,
},

View File

@@ -43,7 +43,7 @@ export default async function SettingsLayout({ children, params }) {
membershipRole={currentUserMembership?.role}
/>
<div className="w-full md:ml-64">
<div className="max-w-4xl px-20 pb-6 pt-14 md:pt-6">
<div className="px-20 pb-6 pt-14 md:pt-6">
<div>{children}</div>
</div>
</div>

View File

@@ -0,0 +1,406 @@
"use client";
import UnifiedStylingPreviewSurvey from "@/app/(app)/environments/[environmentId]/settings/lookandfeel/components/UnifiedStylingPreviewSurvey";
import { RotateCcwIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import React, { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { TProduct } from "@formbricks/types/product";
import { TSurvey } from "@formbricks/types/surveys";
import { Button } from "@formbricks/ui/Button";
import { ColorPicker } from "@formbricks/ui/ColorPicker";
import { Slider } from "@formbricks/ui/Slider";
import CardArrangement from "@formbricks/ui/Styling/CardArrangement";
import ColorSelectorWithLabel from "@formbricks/ui/Styling/ColorSelectorWithLabel";
import DarkModeColors from "@formbricks/ui/Styling/DarkModeColors";
import { Switch } from "@formbricks/ui/Switch";
import { updateProductAction } from "../actions";
type UnifiedStylingProps = {
product: TProduct;
};
const colorDefaults = {
brandColor: "#64748b",
questionColor: "#2b2524",
inputColor: "#efefef",
inputBorderColor: "#c0c0c0",
cardBackgroundColor: "#c0c0c0",
highlighBorderColor: "#64748b",
};
const previewSurvey = {
id: "cltcppyqk00006uothzb3ybh0",
createdAt: new Date(),
updatedAt: new Date(),
name: "Product Market Fit (Superhuman)",
type: "link",
environmentId: "cltcf8i2n00099wlx7cu12zi6",
createdBy: "cltcf8i1c00009wlx3sk1ryss",
status: "draft",
welcomeCard: {
html: "Thanks for providing your feedback - let's go!",
enabled: false,
headline: "Welcome!",
timeToFinish: true,
showResponseCount: false,
},
questions: [
{
id: "uvnrhtngswxlibktglanh45f",
type: "openText",
headline: "This is a preview survey",
required: true,
inputType: "text",
subheader: "Click through it to check the look and feel of the surveying experience.",
longAnswer: true,
placeholder: "Type your answer here...",
},
{
id: "swfnndfht0ubsu9uh17tjcej",
type: "rating",
range: 5,
scale: "star",
headline: "How would you rate My Product",
required: true,
subheader: "Don't worry, be honest.",
lowerLabel: "Not good",
upperLabel: "Very good",
},
{
id: "je70a714xjdxc70jhxgv5web",
type: "multipleChoiceSingle",
choices: [
{
id: "vx9q4mlr6ffaw35m99bselwm",
label: "Eat the cake 🍰",
},
{
id: "ynj051qawxd4dszxkbvahoe5",
label: "Have the cake 🎂",
},
],
headline: "What do you do?",
required: true,
subheader: "Can't do both.",
shuffleOption: "none",
},
],
thankYouCard: {
enabled: true,
headline: "Thank you!",
subheader: "We appreciate your feedback.",
buttonLink: "https://formbricks.com/signup",
buttonLabel: "Create your own Survey",
},
hiddenFields: {
enabled: true,
fieldIds: [],
},
displayOption: "displayOnce",
recontactDays: null,
autoClose: null,
closeOnDate: null,
delay: 0,
displayPercentage: null,
autoComplete: null,
verifyEmail: null,
redirectUrl: null,
productOverwrites: null,
styling: null,
surveyClosedMessage: null,
singleUse: {
enabled: false,
isEncrypted: true,
},
pin: null,
resultShareKey: null,
triggers: [],
inlineTriggers: null,
segment: null,
};
const UnifiedStyling = ({ product }: UnifiedStylingProps) => {
const router = useRouter();
const [unifiedStyling, setUnifiedStyling] = useState(product.styling?.unifiedStyling ?? false);
const [allowStyleOverwrite, setAllowStyleOverwrite] = useState(
product.styling?.allowStyleOverwrite ?? false
);
const [brandColor, setBrandColor] = useState(
product.styling?.brandColor?.light ?? colorDefaults.brandColor
);
const [questionColor, setQuestionColor] = useState(
product.styling?.questionColor?.light ?? colorDefaults.questionColor
);
const [inputColor, setInputColor] = useState(
product.styling?.inputColor?.light ?? colorDefaults.inputColor
);
const [inputBorderColor, setInputBorderColor] = useState(
product.styling?.inputBorderColor?.light ?? colorDefaults.inputBorderColor
);
const [cardBackgroundColor, setCardBackgroundColor] = useState(
product.styling?.cardBackgroundColor?.light ?? colorDefaults.cardBackgroundColor
);
// highlight border
const [allowHighlightBorder, setAllowHighlightBorder] = useState(
!!product.styling?.highlightBorderColor?.light ?? false
);
const [highlightBorderColor, setHighlightBorderColor] = useState(
product.styling?.highlightBorderColor?.light ?? colorDefaults.highlighBorderColor
);
const [isDarkMode, setIsDarkMode] = useState(product.styling?.isDarkModeEnabled ?? false);
const [brandColorDark, setBrandColorDark] = useState(product.styling?.brandColor?.dark);
const [questionColorDark, setQuestionColorDark] = useState(product.styling?.questionColor?.dark);
const [inputColorDark, setInputColorDark] = useState(product.styling?.inputColor?.dark);
const [inputBorderColorDark, setInputBorderColorDark] = useState(product.styling?.inputBorderColor?.dark);
const [cardBackgroundColorDark, setCardBackgroundColorDark] = useState(
product.styling?.cardBackgroundColor?.dark
);
const [highlightBorderColorDark, setHighlightBorderColorDark] = useState(
product.styling?.highlightBorderColor?.dark
);
const [roundness, setRoundness] = useState(product.styling?.roundness ?? 8);
const [linkSurveysCardArrangement, setLinkSurveysCardArrangement] = useState(
product.styling?.cardArrangement?.linkSurveys ?? "casual"
);
const [inAppSurveysCardArrangement, setInAppSurveysCardArrangement] = useState(
product.styling?.cardArrangement?.inAppSurveys ?? "casual"
);
const [activeQuestionId, setActiveQuestionId] = useState<string | null>(null);
useEffect(() => {
setActiveQuestionId(previewSurvey.questions[0].id);
}, []);
useEffect(() => {
if (!unifiedStyling) {
setAllowStyleOverwrite(false);
}
}, [unifiedStyling]);
const onSave = async () => {
await updateProductAction(product.id, {
styling: {
unifiedStyling,
allowStyleOverwrite,
brandColor: {
light: brandColor,
dark: brandColorDark,
},
questionColor: {
light: questionColor,
dark: questionColorDark,
},
inputColor: {
light: inputColor,
dark: inputColorDark,
},
inputBorderColor: {
light: inputBorderColor,
dark: inputBorderColorDark,
},
cardBackgroundColor: {
light: cardBackgroundColor,
dark: cardBackgroundColorDark,
},
highlightBorderColor: allowHighlightBorder
? {
light: highlightBorderColor,
dark: highlightBorderColorDark,
}
: undefined,
isDarkModeEnabled: isDarkMode,
roundness,
cardArrangement: {
linkSurveys: linkSurveysCardArrangement,
inAppSurveys: inAppSurveysCardArrangement,
},
},
});
toast.success("Styling updated successfully.");
router.refresh();
};
return (
<div className="flex">
{/* Styling settings */}
<div className="w-1/2 pr-6">
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-4 rounded-lg bg-slate-50 p-4">
<div className="flex items-center gap-6">
<Switch
checked={unifiedStyling}
onCheckedChange={(value) => {
setUnifiedStyling(value);
}}
/>
<div className="flex flex-col">
<h3 className="text-base font-semibold">Enable unified styling</h3>
<p className="text-sm text-slate-800">Set base styles for all surveys below</p>
</div>
</div>
<div className="flex items-center gap-6">
<Switch
checked={allowStyleOverwrite}
onCheckedChange={(value) => {
setAllowStyleOverwrite(value);
}}
disabled={!unifiedStyling}
/>
<div className="flex flex-col">
<h3 className="text-base font-semibold">Allow overwriting styles</h3>
<p className="text-sm text-slate-800">
Activate if you want some surveys to be styled differently
</p>
</div>
</div>
</div>
<ColorSelectorWithLabel
label="Brand color"
color={brandColor}
setColor={setBrandColor}
description="Change the text color of the survey questions."
disabled
/>
<ColorSelectorWithLabel
label="Question color"
color={questionColor}
setColor={setQuestionColor}
description="Change the text color of the survey questions."
/>
<ColorSelectorWithLabel
label="Input color"
color={inputColor}
setColor={setInputColor}
description="Change the text color of the survey questions."
/>
<ColorSelectorWithLabel
label="Input border color"
color={inputBorderColor}
setColor={setInputBorderColor}
description="Change the text color of the survey questions."
/>
<ColorSelectorWithLabel
label="Card background color"
color={cardBackgroundColor}
setColor={setCardBackgroundColor}
description="Change the text color of the survey questions."
/>
<div className="flex flex-col gap-4">
<div className="flex items-center gap-6">
<Switch
checked={allowHighlightBorder}
onCheckedChange={(value) => {
setAllowHighlightBorder(value);
}}
disabled={!unifiedStyling}
/>
<div className="flex flex-col">
<h3 className="text-base font-semibold">Add highlight border</h3>
<p className="text-sm text-slate-800">Add on outer border to your survey card</p>
</div>
</div>
{allowHighlightBorder && (
<ColorPicker
color={highlightBorderColor}
onChange={setHighlightBorderColor}
containerClass="my-0"
/>
)}
</div>
<DarkModeColors
isDarkMode={isDarkMode}
setIsDarkMode={setIsDarkMode}
brandColor={brandColorDark}
cardBackgroundColor={cardBackgroundColorDark}
highlightBorderColor={highlightBorderColorDark}
inputBorderColor={inputBorderColorDark}
inputColor={inputColorDark}
questionColor={questionColorDark}
setBrandColor={setBrandColorDark}
setCardBackgroundColor={setCardBackgroundColorDark}
setHighlighBorderColor={setHighlightBorderColorDark}
setInputBorderColor={setInputBorderColorDark}
setInputColor={setInputColorDark}
setQuestionColor={setQuestionColorDark}
/>
<div className="flex flex-col gap-4">
<div className="flex flex-col">
<h3 className="text-base font-semibold text-slate-900">Roundness</h3>
<p className="text-sm text-slate-800">Change the border radius of the card and the inputs.</p>
</div>
<Slider
value={[roundness]}
max={16}
onValueChange={(value) => setRoundness(value[0])}
disabled={!unifiedStyling}
/>
</div>
<CardArrangement
activeCardArrangement={linkSurveysCardArrangement}
surveyType="link"
setActiveCardArrangement={setLinkSurveysCardArrangement}
/>
<CardArrangement
activeCardArrangement={inAppSurveysCardArrangement}
surveyType="web"
setActiveCardArrangement={setInAppSurveysCardArrangement}
/>
</div>
<div className="mt-8 flex items-center justify-end gap-2">
<Button variant="minimal" className="flex items-center gap-2">
Reset
<RotateCcwIcon className="h-4 w-4" />
</Button>
<Button variant="darkCTA" onClick={onSave}>
Save changes
</Button>
</div>
</div>
{/* Survey Preview */}
<div className="w-1/2 bg-slate-100 pt-4">
<div className="h-full max-h-[800px]">
<UnifiedStylingPreviewSurvey
activeQuestionId={activeQuestionId}
setActiveQuestionId={setActiveQuestionId}
survey={previewSurvey as TSurvey}
product={product}
/>
</div>
</div>
</div>
);
};
export default UnifiedStyling;

View File

@@ -0,0 +1,261 @@
"use client";
import Modal from "@/app/(app)/environments/[environmentId]/surveys/components/Modal";
import { MediaBackground } from "@/app/s/[surveyId]/components/MediaBackground";
import { ArrowPathRoundedSquareIcon } from "@heroicons/react/24/outline";
import { ArrowsPointingInIcon, ArrowsPointingOutIcon } from "@heroicons/react/24/solid";
import { Variants, motion } from "framer-motion";
import { useEffect, useMemo, useRef, useState } from "react";
import type { TProduct } from "@formbricks/types/product";
import { TStyling } from "@formbricks/types/styling";
import { TSurvey } from "@formbricks/types/surveys";
import { Button } from "@formbricks/ui/Button";
import { SurveyInline } from "@formbricks/ui/Survey";
interface UnifiedStylingPreviewSurveyProps {
survey: TSurvey;
setActiveQuestionId: (id: string | null) => void;
activeQuestionId?: string | null;
product: TProduct;
}
let surveyNameTemp;
const previewParentContainerVariant: Variants = {
expanded: {
position: "fixed",
height: "100%",
width: "100%",
backgroundColor: "rgba(0, 0, 0, 0.4)",
backdropFilter: "blur(15px)",
left: 0,
top: 0,
zIndex: 1040,
transition: {
ease: "easeIn",
duration: 0.001,
},
},
shrink: {
display: "none",
position: "fixed",
backgroundColor: "rgba(0, 0, 0, 0.0)",
backdropFilter: "blur(0px)",
transition: {
duration: 0,
},
zIndex: -1,
},
};
export default function UnifiedStylingPreviewSurvey({
setActiveQuestionId,
activeQuestionId,
survey,
product,
}: UnifiedStylingPreviewSurveyProps) {
const [isModalOpen, setIsModalOpen] = useState(true);
const [isFullScreenPreview, setIsFullScreenPreview] = useState(false);
const [previewPosition, setPreviewPosition] = useState("relative");
const ContentRef = useRef<HTMLDivElement | null>(null);
const [shrink, setshrink] = useState(false);
const [previewType, setPreviewType] = useState<"link" | "web">("link");
const { productOverwrites } = survey || {};
const previewScreenVariants: Variants = {
expanded: {
right: "5%",
bottom: "10%",
top: "12%",
width: "40%",
position: "fixed",
height: "80%",
zIndex: 1050,
boxShadow: "0px 4px 5px 4px rgba(169, 169, 169, 0.25)",
transition: {
ease: "easeInOut",
duration: shrink ? 0.3 : 0,
},
},
expanded_with_fixed_positioning: {
zIndex: 1050,
position: "fixed",
top: "5%",
right: "5%",
bottom: "10%",
width: "90%",
height: "90%",
transition: {
ease: "easeOut",
duration: 0.4,
},
},
shrink: {
display: "relative",
width: ["83.33%"],
height: ["95%"],
},
};
const { placement: surveyPlacement } = productOverwrites || {};
const placement = surveyPlacement || product.placement;
const highlightBorderColor = product.styling?.highlightBorderColor?.light;
const styling: TStyling = useMemo(() => {
if (product.styling) {
return product.styling;
}
return {
unifiedStyling: true,
allowStyleOverwrite: true,
brandColor: {
light: product.brandColor || "#64748b",
},
};
}, [product.brandColor, product.styling]);
// this useEffect is fo refreshing the survey preview only if user is switching between templates on survey templates page and hence we are checking for survey.id === "someUniqeId1" which is a common Id for all templates
useEffect(() => {
if (survey.name !== surveyNameTemp && survey.id === "someUniqueId1") {
resetQuestionProgress();
surveyNameTemp = survey.name;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [survey]);
useEffect(() => {
if (previewType === "web") {
setIsModalOpen(true);
}
}, [previewType]);
function resetQuestionProgress() {
setActiveQuestionId(survey?.questions[0]?.id);
}
const onFileUpload = async (file: File) => file.name;
return (
<div className="flex h-full w-full flex-col items-center justify-items-center">
<motion.div
variants={previewParentContainerVariant}
className="fixed hidden h-[95%] w-5/6"
animate={isFullScreenPreview ? "expanded" : "shrink"}
/>
<motion.div
layout
variants={previewScreenVariants}
animate={
isFullScreenPreview
? previewPosition === "relative"
? "expanded"
: "expanded_with_fixed_positioning"
: "shrink"
}
className="relative flex h-[95] max-h-[95%] w-5/6 items-center justify-center rounded-lg border border-slate-300 bg-slate-200">
<div className="flex h-full w-5/6 flex-1 flex-col">
<div className="flex h-8 w-full items-center rounded-t-lg bg-slate-100">
<div className="ml-6 flex space-x-2">
<div className="h-3 w-3 rounded-full bg-red-500"></div>
<div className="h-3 w-3 rounded-full bg-amber-500"></div>
<div className="h-3 w-3 rounded-full bg-emerald-500"></div>
</div>
<div className="ml-4 flex w-full justify-between font-mono text-sm text-slate-400">
<p>{previewType === "web" ? "Your web app" : "Preview"}</p>
<div className="flex items-center">
{isFullScreenPreview ? (
<ArrowsPointingInIcon
className="mr-2 h-4 w-4 cursor-pointer"
onClick={() => {
setshrink(true);
setPreviewPosition("relative");
setTimeout(() => setIsFullScreenPreview(false), 300);
}}
/>
) : (
<ArrowsPointingOutIcon
className="mr-2 h-4 w-4 cursor-pointer"
onClick={() => {
setshrink(false);
setIsFullScreenPreview(true);
setTimeout(() => setPreviewPosition("fixed"), 300);
}}
/>
)}
<ResetProgressButton resetQuestionProgress={resetQuestionProgress} />
</div>
</div>
</div>
{previewType === "web" ? (
<Modal
isOpen
placement={placement}
highlightBorderColor={highlightBorderColor}
previewMode="desktop"
borderRadius={styling.roundness ?? 12}>
<SurveyInline
survey={survey}
activeQuestionId={activeQuestionId || undefined}
isBrandingEnabled={product.inAppSurveyBranding}
onActiveQuestionChange={setActiveQuestionId}
isRedirectDisabled={true}
onFileUpload={onFileUpload}
styling={styling}
/>
</Modal>
) : (
<MediaBackground survey={survey} ContentRef={ContentRef} isEditorView>
<div className="z-0 w-full max-w-md rounded-lg p-4">
<SurveyInline
survey={survey}
activeQuestionId={activeQuestionId || undefined}
isBrandingEnabled={product.linkSurveyBranding}
onActiveQuestionChange={setActiveQuestionId}
isRedirectDisabled={true}
onFileUpload={onFileUpload}
responseCount={42}
styling={styling}
/>
</div>
</MediaBackground>
)}
</div>
</motion.div>
{/* for toggling between mobile and desktop mode */}
<div className="mt-2 flex rounded-full border-2 border-slate-300 p-1">
<div
className={`${previewType === "link" ? "rounded-full bg-slate-200" : ""} cursor-pointer px-3 py-1`}
onClick={() => setPreviewType("link")}>
Link survey
</div>
<div
className={`${previewType === "web" ? "rounded-full bg-slate-200" : ""} cursor-pointer px-3 py-1`}
onClick={() => setPreviewType("web")}>
App survey
</div>
</div>
</div>
);
}
function ResetProgressButton({ resetQuestionProgress }) {
return (
<Button
variant="minimal"
className="py-0.2 mr-2 bg-white px-2 font-sans text-sm text-slate-500"
onClick={resetQuestionProgress}>
Restart
<ArrowPathRoundedSquareIcon className="ml-2 h-4 w-4" />
</Button>
);
}

View File

@@ -18,6 +18,7 @@ import { EditBrandColor } from "./components/EditBrandColor";
import { EditFormbricksBranding } from "./components/EditBranding";
import { EditHighlightBorder } from "./components/EditHighlightBorder";
import { EditPlacement } from "./components/EditPlacement";
import UnifiedStyling from "./components/UnifiedStyling";
export default async function ProfileSettingsPage({ params }: { params: { environmentId: string } }) {
const [session, team, product] = await Promise.all([
@@ -50,19 +51,24 @@ export default async function ProfileSettingsPage({ params }: { params: { enviro
return (
<div>
<SettingsTitle title="Look & Feel" />
<SettingsCard title="Brand Color" description="Match the surveys with your user interface.">
<SettingsCard
title="Unified Styling"
description="Set styling for ALL surveys in this project. You can still overwrite these styles in the survey editor.">
<UnifiedStyling product={product} />
</SettingsCard>
{/* <SettingsCard title="Brand Color" description="Match the surveys with your user interface.">
<EditBrandColor
product={product}
isBrandColorDisabled={isBrandColorEditDisabled}
environmentId={params.environmentId}
/>
</SettingsCard>
</SettingsCard> */}
<SettingsCard
title="In-app Survey Placement"
description="Change where surveys will be shown in your web app.">
<EditPlacement product={product} environmentId={params.environmentId} />
</SettingsCard>
<SettingsCard
{/* <SettingsCard
noPadding
title="Highlight Border"
description="Make sure your users notice the survey you display">
@@ -71,7 +77,7 @@ export default async function ProfileSettingsPage({ params }: { params: { enviro
defaultBrandColor={DEFAULT_BRAND_COLOR}
environmentId={params.environmentId}
/>
</SettingsCard>
</SettingsCard> */}
<SettingsCard
title="Formbricks Branding"
description="We love your support but understand if you toggle it off.">

View File

@@ -7,7 +7,7 @@ import {
resendInviteAction,
} from "@/app/(app)/environments/[environmentId]/settings/members/actions";
import ShareInviteModal from "@/app/(app)/environments/[environmentId]/settings/members/components/ShareInviteModal";
import { SendHorizonalIcon, ShareIcon, TrashIcon } from "lucide-react";
import { PaperAirplaneIcon, ShareIcon, TrashIcon } from "@heroicons/react/24/outline";
import { useRouter } from "next/navigation";
import React, { useMemo, useState } from "react";
import toast from "react-hot-toast";
@@ -127,7 +127,7 @@ export default function MemberActions({ team, member, invite, showDeleteButton }
handleResendInvite();
}}
id="resendInviteButton">
<SendHorizonalIcon className="h-5 w-5 text-slate-700 hover:text-slate-500" />
<PaperAirplaneIcon className="h-5 w-5 text-slate-700 hover:text-slate-500" />
</button>
</TooltipTrigger>
<TooltipContent className="TooltipContent" sideOffset={5}>

View File

@@ -1,6 +1,7 @@
"use client";
import { CheckIcon, CopyIcon } from "lucide-react";
import { CheckIcon } from "@heroicons/react/24/outline";
import { DocumentDuplicateIcon } from "@heroicons/react/24/solid";
import { useRef } from "react";
import toast from "react-hot-toast";
@@ -58,7 +59,7 @@ export default function ShareInviteModal({ inviteToken, open, setOpen }: ShareIn
}}
title="Copy invite link to clipboard"
aria-label="Copy invite link to clipboard"
EndIcon={CopyIcon}>
EndIcon={DocumentDuplicateIcon}>
Copy URL
</Button>
</div>

View File

@@ -1,4 +1,4 @@
import { HelpCircleIcon, UsersIcon } from "lucide-react";
import { QuestionMarkCircleIcon, UsersIcon } from "@heroicons/react/24/solid";
import Link from "next/link";
import { TUser } from "@formbricks/types/user";
@@ -51,9 +51,9 @@ export default function EditAlerts({
<TooltipProvider delayDuration={50}>
<Tooltip>
<TooltipTrigger>
<div className="col-span-1 flex cursor-default items-center justify-center space-x-2">
<div className="col-span-1 flex cursor-default items-center justify-center">
<span className="">Every Response</span>
<HelpCircleIcon className="h-4 w-4 flex-shrink-0 text-slate-500" />
<QuestionMarkCircleIcon className="h-4 w-4 flex-shrink-0 text-slate-500" />
</div>
</TooltipTrigger>
<TooltipContent>Sends complete responses, no partials.</TooltipContent>

View File

@@ -1,4 +1,4 @@
import { UsersIcon } from "lucide-react";
import { UsersIcon } from "@heroicons/react/24/solid";
import Link from "next/link";
import { TUser } from "@formbricks/types/user";

View File

@@ -40,23 +40,6 @@ export const updateProductAction = async (
throw new AuthorizationError("Not authorized");
}
const team = await getTeamByEnvironmentId(environmentId);
const membership = team ? await getMembershipByUserIdTeamId(session.user.id, team.id) : null;
if (!membership) {
throw new AuthorizationError("Not authorized");
}
if (membership.role === "viewer") {
throw new AuthorizationError("Not authorized");
}
if (membership.role === "developer") {
if (!!data.name || !!data.brandColor || !!data.teamId || !!data.environments) {
throw new AuthorizationError("Not authorized");
}
}
const updatedProduct = await updateProduct(productId, data);
return updatedProduct;
};

View File

@@ -42,6 +42,7 @@ if (typeof window !== "undefined") {
formbricks.init({
environmentId: "${environmentId}",
apiHost: "${webAppUrl}",
debug: true, // remove when in production
});
}`}</CodeBlock>

View File

@@ -6,7 +6,7 @@ import {
updateTagNameAction,
} from "@/app/(app)/environments/[environmentId]/settings/tags/actions";
import MergeTagsCombobox from "@/app/(app)/environments/[environmentId]/settings/tags/components/MergeTagsCombobox";
import { AlertCircleIcon } from "lucide-react";
import { ExclamationCircleIcon } from "@heroicons/react/24/solid";
import { useRouter } from "next/navigation";
import React, { useState } from "react";
import { toast } from "react-hot-toast";
@@ -69,7 +69,7 @@ const SingleTag: React.FC<{
if (error?.message.includes("Unique constraint failed on the fields")) {
toast.error("Tag already exists", {
duration: 2000,
icon: <AlertCircleIcon className="h-5 w-5 text-orange-500" />,
icon: <ExclamationCircleIcon className="h-5 w-5 text-orange-500" />,
});
} else {
toast.error(error?.message ?? "Something went wrong", {

View File

@@ -4,10 +4,10 @@ import { getServerSession } from "next-auth";
import { revalidatePath } from "next/cache";
import { authOptions } from "@formbricks/lib/authOptions";
import { getResponseCountBySurveyId, getResponses, getSurveySummary } from "@formbricks/lib/response/service";
import { getResponses } from "@formbricks/lib/response/service";
import { canUserAccessSurvey } from "@formbricks/lib/survey/auth";
import { AuthorizationError } from "@formbricks/types/errors";
import { TResponse, TResponseFilterCriteria, TSurveySummary } from "@formbricks/types/responses";
import { TResponse, TResponseFilterCriteria } from "@formbricks/types/responses";
export default async function revalidateSurveyIdPath(environmentId: string, surveyId: string) {
revalidatePath(`/environments/${environmentId}/surveys/${surveyId}`);
@@ -45,29 +45,3 @@ export async function getResponsesAction(
const responses = await getResponses(surveyId, page, batchSize, filterCriteria);
return responses;
}
export const getSurveySummaryAction = async (
surveyId: string,
filterCriteria?: TResponseFilterCriteria
): Promise<TSurveySummary> => {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
const isAuthorized = await canUserAccessSurvey(session.user.id, surveyId);
if (!isAuthorized) throw new AuthorizationError("Not authorized");
return await getSurveySummary(surveyId, filterCriteria);
};
export const getResponseCountAction = async (
surveyId: string,
filters?: TResponseFilterCriteria
): Promise<number> => {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");
const isAuthorized = await canUserAccessSurvey(session.user.id, surveyId);
if (!isAuthorized) throw new AuthorizationError("Not authorized");
return await getResponseCountBySurveyId(surveyId, filters);
};

View File

@@ -1,5 +1,5 @@
import revalidateSurveyIdPath from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
import { InboxIcon, PresentationIcon } from "lucide-react";
import { InboxStackIcon, PresentationChartLineIcon } from "@heroicons/react/24/solid";
import Link from "next/link";
import { cn } from "@formbricks/lib/cn";
@@ -8,26 +8,20 @@ interface SurveyResultsTabProps {
activeId: string;
environmentId: string;
surveyId: string;
responseCount: number | null;
}
export default function SurveyResultsTab({
activeId,
environmentId,
surveyId,
responseCount,
}: SurveyResultsTabProps) {
export default function SurveyResultsTab({ activeId, environmentId, surveyId }: SurveyResultsTabProps) {
const tabs = [
{
id: "summary",
label: "Summary",
icon: <PresentationIcon className="h-5 w-5" />,
icon: <PresentationChartLineIcon />,
href: `/environments/${environmentId}/surveys/${surveyId}/summary?referer=true`,
},
{
id: "responses",
label: `Responses ${responseCount !== null ? `(${responseCount})` : ""}`,
icon: <InboxIcon className="h-5 w-5" />,
label: "Responses",
icon: <InboxStackIcon />,
href: `/environments/${environmentId}/surveys/${surveyId}/responses?referer=true`,
},
];

View File

@@ -0,0 +1,19 @@
import { getDisplayCountBySurveyId } from "@formbricks/lib/display/service";
import { getResponses } from "@formbricks/lib/response/service";
import { getSurvey } from "@formbricks/lib/survey/service";
import { getTeamByEnvironmentId } from "@formbricks/lib/team/service";
export const getAnalysisData = async (surveyId: string, environmentId: string) => {
const [survey, team, responses, displayCount] = await Promise.all([
getSurvey(surveyId),
getTeamByEnvironmentId(environmentId),
getResponses(surveyId),
getDisplayCountBySurveyId(surveyId),
]);
if (!survey) throw new Error(`Survey not found: ${surveyId}`);
if (!team) throw new Error(`Team not found for environment: ${environmentId}`);
if (survey.environmentId !== environmentId) throw new Error(`Survey not found: ${surveyId}`);
const responseCount = responses.length;
return { responses, responseCount, survey, displayCount };
};

View File

@@ -1,10 +1,7 @@
"use client";
import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
import {
getResponseCountAction,
getResponsesAction,
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
import { getResponsesAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
import SurveyResultsTabs from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyResultsTabs";
import ResponseTimeline from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTimeline";
import CustomFilter from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter";
@@ -50,7 +47,6 @@ const ResponsePage = ({
responsesPerPage,
membershipRole,
}: ResponsePageProps) => {
const [responseCount, setResponseCount] = useState<number | null>(null);
const [responses, setResponses] = useState<TResponse[]>([]);
const [page, setPage] = useState<number>(1);
const [hasMore, setHasMore] = useState<boolean>(true);
@@ -68,6 +64,23 @@ const ResponsePage = ({
return checkForRecallInHeadline(survey);
}, [survey]);
useEffect(() => {
if (!searchParams?.get("referer")) {
resetState();
}
}, [searchParams, resetState]);
useEffect(() => {
const fetchInitialResponses = async () => {
const responses = await getResponsesAction(surveyId, 1, responsesPerPage, filters);
if (responses.length < responsesPerPage) {
setHasMore(false);
}
setResponses(responses);
};
fetchInitialResponses();
}, [surveyId, filters, responsesPerPage]);
const fetchNextPage = useCallback(async () => {
const newPage = page + 1;
const newResponses = await getResponsesAction(surveyId, newPage, responsesPerPage, filters);
@@ -86,35 +99,9 @@ const ResponsePage = ({
setResponses(responses.map((response) => (response.id === responseId ? updatedResponse : response)));
};
useEffect(() => {
if (!searchParams?.get("referer")) {
resetState();
}
}, [searchParams, resetState]);
useEffect(() => {
const fetchInitialResponses = async () => {
const responses = await getResponsesAction(surveyId, 1, responsesPerPage, filters);
if (responses.length < responsesPerPage) {
setHasMore(false);
}
setResponses(responses);
};
fetchInitialResponses();
}, [surveyId, filters, responsesPerPage]);
useEffect(() => {
const handleResponsesCount = async () => {
const responseCount = await getResponseCountAction(surveyId, filters);
setResponseCount(responseCount);
};
handleResponsesCount();
}, [filters, surveyId]);
useEffect(() => {
setPage(1);
setHasMore(true);
setResponses([]);
}, [filters]);
return (
@@ -132,12 +119,7 @@ const ResponsePage = ({
<CustomFilter environmentTags={environmentTags} attributes={attributes} survey={survey} />
<ResultsShareButton survey={survey} webAppUrl={webAppUrl} product={product} user={user} />
</div>
<SurveyResultsTabs
activeId="responses"
environmentId={environment.id}
surveyId={surveyId}
responseCount={responseCount}
/>
<SurveyResultsTabs activeId="responses" environmentId={environment.id} surveyId={surveyId} />
<ResponseTimeline
environment={environment}
surveyId={surveyId}

View File

@@ -3,8 +3,6 @@
import EmptyInAppSurveys from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/EmptyInAppSurveys";
import React, { useEffect, useRef } from "react";
import { useMembershipRole } from "@formbricks/lib/membership/hooks/useMembershipRole";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { TEnvironment } from "@formbricks/types/environment";
import { TResponse } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys";
@@ -62,9 +60,6 @@ export default function ResponseTimeline({
};
}, [fetchNextPage, hasMore]);
const { membershipRole } = useMembershipRole(survey.environmentId);
const { isViewer } = getAccessFlags(membershipRole);
return (
<div className="space-y-4">
{survey.type === "web" && responses.length === 0 && !environment.widgetSetupCompleted ? (
@@ -89,7 +84,6 @@ export default function ResponseTimeline({
environment={environment}
updateResponse={updateResponse}
deleteResponse={deleteResponse}
isViewer={isViewer}
/>
</div>
);

View File

@@ -9,7 +9,6 @@ import { authOptions } from "@formbricks/lib/authOptions";
import { sendEmbedSurveyPreviewEmail } from "@formbricks/lib/emails/emails";
import { canUserAccessSurvey } from "@formbricks/lib/survey/auth";
import { getSurvey, updateSurvey } from "@formbricks/lib/survey/service";
import { formatSurveyDateFields } from "@formbricks/lib/survey/util";
import { AuthenticationError, AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors";
type TSendEmailActionArgs = {
@@ -55,7 +54,7 @@ export async function generateResultShareUrlAction(surveyId: string): Promise<st
20
)();
await updateSurvey({ ...formatSurveyDateFields(survey), resultShareKey });
await updateSurvey({ ...survey, resultShareKey });
return resultShareKey;
}
@@ -87,7 +86,7 @@ export async function deleteResultShareUrlAction(surveyId: string): Promise<void
throw new ResourceNotFoundError("Survey", surveyId);
}
await updateSurvey({ ...formatSurveyDateFields(survey), resultShareKey: null });
await updateSurvey({ ...survey, resultShareKey: null });
}
export const getEmailHtmlAction = async (surveyId: string) => {

View File

@@ -1,17 +1,34 @@
import Headline from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/Headline";
import { questionTypes } from "@/app/lib/questions";
import { InboxIcon } from "lucide-react";
import { InboxStackIcon } from "@heroicons/react/24/solid";
import { useMemo } from "react";
import { TSurveySummaryCta } from "@formbricks/types/responses";
import type { TSurveyQuestionSummary } from "@formbricks/types/surveys";
import { TSurveyCTAQuestion } from "@formbricks/types/surveys";
import { ProgressBar } from "@formbricks/ui/ProgressBar";
interface CTASummaryProps {
questionSummary: TSurveySummaryCta;
questionSummary: TSurveyQuestionSummary<TSurveyCTAQuestion>;
}
interface ChoiceResult {
count: number;
percentage: number;
}
export default function CTASummary({ questionSummary }: CTASummaryProps) {
const questionTypeInfo = questionTypes.find((type) => type.id === questionSummary.question.type);
const ctr: ChoiceResult = useMemo(() => {
const clickedAbs = questionSummary.responses.filter((response) => response.value === "clicked").length;
const count = questionSummary.responses.length;
if (count === 0) return { count: 0, percentage: 0 };
return {
count: count,
percentage: clickedAbs / count,
};
}, [questionSummary]);
return (
<div className=" rounded-lg border border-slate-200 bg-slate-50 shadow-sm">
<div className="space-y-2 px-4 pb-5 pt-6 md:px-6">
@@ -23,8 +40,8 @@ export default function CTASummary({ questionSummary }: CTASummaryProps) {
{questionTypeInfo ? questionTypeInfo.label : "Unknown Question Type"}
</div>
<div className=" flex items-center rounded-lg bg-slate-100 p-2">
<InboxIcon className="mr-2 h-4 w-4 " />
{questionSummary.responseCount} responses
<InboxStackIcon className="mr-2 h-4 w-4 " />
{ctr.count} responses
</div>
{!questionSummary.question.required && (
<div className="flex items-center rounded-lg bg-slate-100 p-2">Optional</div>
@@ -37,15 +54,15 @@ export default function CTASummary({ questionSummary }: CTASummaryProps) {
<p className="font-semibold text-slate-700">Clickthrough Rate (CTR)</p>
<div>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
{Math.round(questionSummary.ctr.percentage)}%
{Math.round(ctr.percentage * 100)}%
</p>
</div>
</div>
<p className="flex w-32 items-end justify-end text-slate-600">
{questionSummary.ctr.count} {questionSummary.ctr.count === 1 ? "response" : "responses"}
{ctr.count} {ctr.count === 1 ? "response" : "responses"}
</p>
</div>
<ProgressBar barColor="bg-brand" progress={questionSummary.ctr.percentage / 100} />
<ProgressBar barColor="bg-brand" progress={ctr.percentage} />
</div>
</div>
);

View File

@@ -1,16 +1,19 @@
import Headline from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/Headline";
import { questionTypes } from "@/app/lib/questions";
import { InboxIcon } from "lucide-react";
import { InboxStackIcon } from "@heroicons/react/24/solid";
import Link from "next/link";
import { TSurveySummaryCal } from "@formbricks/types/responses";
import { ProgressBar } from "@formbricks/ui/ProgressBar";
import { getPersonIdentifier } from "@formbricks/lib/person/util";
import { timeSince } from "@formbricks/lib/time";
import { TSurveyCalQuestion, TSurveyQuestionSummary } from "@formbricks/types/surveys";
import { PersonAvatar } from "@formbricks/ui/Avatars";
interface CalSummaryProps {
questionSummary: TSurveySummaryCal;
questionSummary: TSurveyQuestionSummary<TSurveyCalQuestion>;
environmentId: string;
}
export default function CalSummary({ questionSummary }: CalSummaryProps) {
export default function CalSummary({ questionSummary, environmentId }: CalSummaryProps) {
const questionTypeInfo = questionTypes.find((type) => type.id === questionSummary.question.type);
return (
@@ -24,47 +27,51 @@ export default function CalSummary({ questionSummary }: CalSummaryProps) {
{questionTypeInfo ? questionTypeInfo.label : "Unknown Question Type"} Question
</div>
<div className=" flex items-center rounded-lg bg-slate-100 p-2">
<InboxIcon className="mr-2 h-4 w-4" />
{questionSummary.responseCount} Responses
<InboxStackIcon className="mr-2 h-4 w-4" />
{questionSummary.responses.length} Responses
</div>
{!questionSummary.question.required && (
<div className="flex items-center rounded-lg bg-slate-100 p-2">Optional</div>
)}
</div>
</div>
<div className="space-y-5 rounded-b-lg bg-white px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
<div>
<div className="text flex justify-between px-2 pb-2">
<div className="mr-8 flex space-x-1">
<p className="font-semibold text-slate-700">Booked</p>
<div>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
{Math.round(questionSummary.booked.percentage)}%
</p>
</div>
</div>
<p className="flex w-32 items-end justify-end text-slate-600">
{questionSummary.booked.count} {questionSummary.booked.count === 1 ? "response" : "responses"}
</p>
</div>
<ProgressBar barColor="bg-brand" progress={questionSummary.booked.percentage / 100} />
<div className="rounded-b-lg bg-white ">
<div className="grid h-10 grid-cols-4 items-center border-y border-slate-200 bg-slate-100 text-sm font-bold text-slate-600">
<div className="pl-4 md:pl-6">User</div>
<div className="col-span-2 pl-4 md:pl-6">Response</div>
<div className="px-4 md:px-6">Time</div>
</div>
<div>
<div className="text flex justify-between px-2 pb-2">
<div className="mr-8 flex space-x-1">
<p className="font-semibold text-slate-700">Dismissed</p>
<div>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
{Math.round(questionSummary.skipped.percentage)}%
</p>
{questionSummary.responses.map((response) => {
const displayIdentifier = response.person ? getPersonIdentifier(response.person) : null;
return (
<div
key={response.id}
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 md:text-base">
<div className="pl-4 md:pl-6">
{response.person ? (
<Link
className="ph-no-capture group flex items-center"
href={`/environments/${environmentId}/people/${response.person.id}`}>
<div className="hidden md:flex">
<PersonAvatar personId={response.person.id} />
</div>
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
{displayIdentifier}
</p>
</Link>
) : (
<div className="group flex items-center">
<div className="hidden md:flex">
<PersonAvatar personId="anonymous" />
</div>
<p className="break-all text-slate-600 md:ml-2">Anonymous</p>
</div>
)}
</div>
<div className="ph-no-capture col-span-2 whitespace-pre-wrap pl-6 font-semibold capitalize">
{response.value}
</div>
<div className="px-4 text-slate-500 md:px-6">{timeSince(response.updatedAt.toISOString())}</div>
</div>
<p className="flex w-32 items-end justify-end text-slate-600">
{questionSummary.skipped.count} {questionSummary.skipped.count === 1 ? "response" : "responses"}
</p>
</div>
<ProgressBar barColor="bg-brand" progress={questionSummary.skipped.percentage / 100} />
</div>
);
})}
</div>
</div>
);

View File

@@ -1,30 +1,54 @@
import Headline from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/Headline";
import { questionTypes } from "@/app/lib/questions";
import { InboxIcon } from "lucide-react";
import { InboxStackIcon } from "@heroicons/react/24/solid";
import { useMemo } from "react";
import { TSurveySummaryConsent } from "@formbricks/types/responses";
import type { TSurveyQuestionSummary } from "@formbricks/types/surveys";
import { TSurveyConsentQuestion } from "@formbricks/types/surveys";
import { ProgressBar } from "@formbricks/ui/ProgressBar";
interface ConsentSummaryProps {
questionSummary: TSurveySummaryConsent;
questionSummary: TSurveyQuestionSummary<TSurveyConsentQuestion>;
}
interface ChoiceResult {
count: number;
acceptedCount: number;
acceptedPercentage: number;
dismissedCount: number;
dismissedPercentage: number;
}
export default function ConsentSummary({ questionSummary }: ConsentSummaryProps) {
const questionTypeInfo = questionTypes.find((type) => type.id === questionSummary.question.type);
const ctr: ChoiceResult = useMemo(() => {
const total = questionSummary.responses.length;
const clickedAbs = questionSummary.responses.filter((response) => response.value !== "dismissed").length;
if (total === 0) {
return { count: 0, acceptedCount: 0, acceptedPercentage: 0, dismissedCount: 0, dismissedPercentage: 0 };
}
return {
count: total,
acceptedCount: clickedAbs,
acceptedPercentage: clickedAbs / total,
dismissedCount: total - clickedAbs,
dismissedPercentage: 1 - clickedAbs / total,
};
}, [questionSummary]);
return (
<div className=" rounded-lg border border-slate-200 bg-slate-50 shadow-sm">
<div className="space-y-2 px-4 pb-5 pt-6 md:px-6">
<Headline headline={questionSummary.question.headline} />
<div className="flex space-x-2 text-xs font-semibold text-slate-600 md:text-sm">
<div className=" flex items-center rounded-lg bg-slate-100 p-2">
{questionTypeInfo && <questionTypeInfo.icon className="mr-2 h-4 w-4 " />}
{questionTypeInfo ? questionTypeInfo.label : "Unknown Question Type"}
</div>
<div className=" flex items-center rounded-lg bg-slate-100 p-2">
<InboxIcon className="mr-2 h-4 w-4 " />
{questionSummary.responseCount} responses
<InboxStackIcon className="mr-2 h-4 w-4 " />
{ctr.count} responses
</div>
{!questionSummary.question.required && (
<div className="flex items-center rounded-lg bg-slate-100 p-2">Optional</div>
@@ -38,16 +62,15 @@ export default function ConsentSummary({ questionSummary }: ConsentSummaryProps)
<p className="font-semibold text-slate-700">Accepted</p>
<div>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
{Math.round(questionSummary.accepted.percentage)}%
{Math.round(ctr.acceptedPercentage * 100)}%
</p>
</div>
</div>
<p className="flex w-32 items-end justify-end text-slate-600">
{questionSummary.accepted.count}{" "}
{questionSummary.accepted.count === 1 ? "response" : "responses"}
{ctr.acceptedCount} {ctr.acceptedCount === 1 ? "response" : "responses"}
</p>
</div>
<ProgressBar barColor="bg-brand" progress={questionSummary.accepted.percentage / 100} />
<ProgressBar barColor="bg-brand" progress={ctr.acceptedPercentage} />
</div>
<div>
<div className="text flex justify-between px-2 pb-2">
@@ -55,16 +78,15 @@ export default function ConsentSummary({ questionSummary }: ConsentSummaryProps)
<p className="font-semibold text-slate-700">Dismissed</p>
<div>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
{Math.round(questionSummary.dismissed.percentage)}%
{Math.round(ctr.dismissedPercentage * 100)}%
</p>
</div>
</div>
<p className="flex w-32 items-end justify-end text-slate-600">
{questionSummary.dismissed.count}{" "}
{questionSummary.dismissed.count === 1 ? "response" : "responses"}
{ctr.dismissedCount} {ctr.dismissedCount === 1 ? "response" : "responses"}
</p>
</div>
<ProgressBar barColor="bg-brand" progress={questionSummary.dismissed.percentage / 100} />
<ProgressBar barColor="bg-brand" progress={ctr.dismissedPercentage} />
</div>
</div>
</div>

View File

@@ -1,21 +1,28 @@
import Headline from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/Headline";
import { questionTypes } from "@/app/lib/questions";
import { InboxIcon } from "lucide-react";
import { InboxStackIcon } from "@heroicons/react/24/solid";
import Link from "next/link";
import { useState } from "react";
import { getPersonIdentifier } from "@formbricks/lib/person/util";
import { timeSince } from "@formbricks/lib/time";
import { formatDateWithOrdinal } from "@formbricks/lib/utils/datetime";
import { TSurveySummaryDate } from "@formbricks/types/responses";
import type { TSurveyDateQuestion, TSurveyQuestionSummary } from "@formbricks/types/surveys";
import { PersonAvatar } from "@formbricks/ui/Avatars";
interface DateQuestionSummary {
questionSummary: TSurveySummaryDate;
questionSummary: TSurveyQuestionSummary<TSurveyDateQuestion>;
environmentId: string;
responsesPerPage: number;
}
export default function DateQuestionSummary({ questionSummary, environmentId }: DateQuestionSummary) {
export default function DateQuestionSummary({
questionSummary,
environmentId,
responsesPerPage,
}: DateQuestionSummary) {
const questionTypeInfo = questionTypes.find((type) => type.id === questionSummary.question.type);
const [displayCount, setDisplayCount] = useState(responsesPerPage);
return (
<div className="rounded-lg border border-slate-200 bg-slate-50 shadow-sm">
@@ -28,8 +35,8 @@ export default function DateQuestionSummary({ questionSummary, environmentId }:
{questionTypeInfo ? questionTypeInfo.label : "Unknown Question Type"} Question
</div>
<div className=" flex items-center rounded-lg bg-slate-100 p-2">
<InboxIcon className="mr-2 h-4 w-4" />
{questionSummary.responseCount} Responses
<InboxStackIcon className="mr-2 h-4 w-4" />
{questionSummary.responses.length} Responses
</div>
{!questionSummary.question.required && (
<div className="flex items-center rounded-lg bg-slate-100 p-2">Optional</div>
@@ -42,39 +49,51 @@ export default function DateQuestionSummary({ questionSummary, environmentId }:
<div className="col-span-2 pl-4 md:pl-6">Response</div>
<div className="px-4 md:px-6">Time</div>
</div>
{questionSummary.samples.map((response) => (
<div
key={response.id}
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 md:text-base">
<div className="pl-4 md:pl-6">
{response.person ? (
<Link
className="ph-no-capture group flex items-center"
href={`/environments/${environmentId}/people/${response.person.id}`}>
<div className="hidden md:flex">
<PersonAvatar personId={response.person.id} />
{questionSummary.responses.slice(0, displayCount).map((response) => {
const displayIdentifier = getPersonIdentifier(response.person!);
return (
<div
key={response.id}
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 md:text-base">
<div className="pl-4 md:pl-6">
{response.person ? (
<Link
className="ph-no-capture group flex items-center"
href={`/environments/${environmentId}/people/${response.person.id}`}>
<div className="hidden md:flex">
<PersonAvatar personId={response.person.id} />
</div>
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
{displayIdentifier}
</p>
</Link>
) : (
<div className="group flex items-center">
<div className="hidden md:flex">
<PersonAvatar personId="anonymous" />
</div>
<p className="break-all text-slate-600 md:ml-2">Anonymous</p>
</div>
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
{getPersonIdentifier(response.person)}
</p>
</Link>
) : (
<div className="group flex items-center">
<div className="hidden md:flex">
<PersonAvatar personId="anonymous" />
</div>
<p className="break-all text-slate-600 md:ml-2">Anonymous</p>
</div>
)}
</div>
<div className="ph-no-capture col-span-2 whitespace-pre-wrap pl-6 font-semibold">
{formatDateWithOrdinal(new Date(response.value as string))}
</div>
<div className="px-4 text-slate-500 md:px-6">
{timeSince(new Date(response.updatedAt).toISOString())}
)}
</div>
<div className="ph-no-capture col-span-2 whitespace-pre-wrap pl-6 font-semibold">
{formatDateWithOrdinal(new Date(response.value as string))}
</div>
<div className="px-4 text-slate-500 md:px-6">{timeSince(response.updatedAt.toISOString())}</div>
</div>
);
})}
{displayCount < questionSummary.responses.length && (
<div className="my-1 flex justify-center">
<button
type="button"
onClick={() => setDisplayCount((prevCount) => prevCount + responsesPerPage)}
className="my-2 flex h-8 items-center justify-center rounded-lg border border-slate-300 bg-white px-3 text-sm text-slate-500 hover:bg-slate-100 hover:text-slate-700">
Show more
</button>
</div>
))}
)}
</div>
</div>
);

View File

@@ -1,16 +1,18 @@
import Headline from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/Headline";
import { questionTypes } from "@/app/lib/questions";
import { DownloadIcon, FileIcon, InboxIcon } from "lucide-react";
import { InboxStackIcon } from "@heroicons/react/24/solid";
import { DownloadIcon, FileIcon } from "lucide-react";
import Link from "next/link";
import { getPersonIdentifier } from "@formbricks/lib/person/util";
import { getOriginalFileNameFromUrl } from "@formbricks/lib/storage/utils";
import { timeSince } from "@formbricks/lib/time";
import { TSurveySummaryFileUpload } from "@formbricks/types/responses";
import type { TSurveyQuestionSummary } from "@formbricks/types/surveys";
import { TSurveyFileUploadQuestion } from "@formbricks/types/surveys";
import { PersonAvatar } from "@formbricks/ui/Avatars";
interface FileUploadSummaryProps {
questionSummary: TSurveySummaryFileUpload;
questionSummary: TSurveyQuestionSummary<TSurveyFileUploadQuestion>;
environmentId: string;
}
@@ -28,8 +30,8 @@ export default function FileUploadSummary({ questionSummary, environmentId }: Fi
{questionTypeInfo ? questionTypeInfo.label : "Unknown Question Type"} Question
</div>
<div className=" flex items-center rounded-lg bg-slate-100 p-2">
<InboxIcon className="mr-2 h-4 w-4" />
{questionSummary.responseCount} Responses
<InboxStackIcon className="mr-2 h-4 w-4" />
{questionSummary.responses.length} Responses
</div>
{!questionSummary.question.required && (
<div className="flex items-center rounded-lg bg-slate-100 p-2">Optional</div>
@@ -42,72 +44,80 @@ export default function FileUploadSummary({ questionSummary, environmentId }: Fi
<div className="col-span-2 pl-4 md:pl-6">Response</div>
<div className="px-4 md:px-6">Time</div>
</div>
{questionSummary.files.map((response) => (
<div
key={response.id}
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 md:text-base">
<div className="pl-4 md:pl-6">
{response.person ? (
<Link
className="ph-no-capture group flex items-center"
href={`/environments/${environmentId}/people/${response.person.id}`}>
<div className="hidden md:flex">
<PersonAvatar personId={response.person.id} />
</div>
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
{getPersonIdentifier(response.person)}
</p>
</Link>
) : (
<div className="group flex items-center">
<div className="hidden md:flex">
<PersonAvatar personId="anonymous" />
</div>
<p className="break-all text-slate-600 md:ml-2">Anonymous</p>
</div>
)}
</div>
{questionSummary.responses.map((response) => {
const displayIdentifier = response.person ? getPersonIdentifier(response.person) : null;
<div className="col-span-2 grid">
{Array.isArray(response.value) &&
(response.value.length > 0 ? (
response.value.map((fileUrl, index) => {
const fileName = getOriginalFileNameFromUrl(fileUrl);
return (
<div className="relative m-2 rounded-lg bg-slate-200" key={fileUrl}>
<a
href={fileUrl as string}
key={index}
download={fileName}
target="_blank"
rel="noopener noreferrer">
<div className="absolute right-0 top-0 m-2">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-slate-50 hover:bg-white">
<DownloadIcon className="h-6 text-slate-500" />
</div>
</div>
</a>
<div className="flex flex-col items-center justify-center p-2">
<FileIcon className="h-6 text-slate-500" />
<p className="mt-2 text-sm text-slate-500 dark:text-slate-400">{fileName}</p>
</div>
</div>
);
})
return (
<div
key={response.id}
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 md:text-base">
<div className="pl-4 md:pl-6">
{response.person ? (
<Link
className="ph-no-capture group flex items-center"
href={`/environments/${environmentId}/people/${response.person.id}`}>
<div className="hidden md:flex">
<PersonAvatar personId={response.person.id} />
</div>
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
{displayIdentifier}
</p>
</Link>
) : (
<div className="group flex items-center">
<div className="hidden md:flex">
<PersonAvatar personId="anonymous" />
</div>
<p className="break-all text-slate-600 md:ml-2">Anonymous</p>
</div>
)}
</div>
<div className="col-span-2 grid">
{response.value === "skipped" && (
<div className="flex w-full flex-col items-center justify-center p-2">
<p className="mt-2 text-sm font-semibold text-slate-500 dark:text-slate-400">skipped</p>
</div>
))}
</div>
)}
<div className="px-4 text-slate-500 md:px-6">
{timeSince(new Date(response.updatedAt).toISOString())}
{Array.isArray(response.value) &&
(response.value.length > 0 ? (
response.value.map((fileUrl, index) => {
const fileName = getOriginalFileNameFromUrl(fileUrl);
return (
<div className="relative m-2 rounded-lg bg-slate-200" key={fileUrl}>
<a
href={fileUrl as string}
key={index}
download={fileName}
target="_blank"
rel="noopener noreferrer">
<div className="absolute right-0 top-0 m-2">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-slate-50 hover:bg-white">
<DownloadIcon className="h-6 text-slate-500" />
</div>
</div>
</a>
<div className="flex flex-col items-center justify-center p-2">
<FileIcon className="h-6 text-slate-500" />
<p className="mt-2 text-sm text-slate-500 dark:text-slate-400">{fileName}</p>
</div>
</div>
);
})
) : (
<div className="flex w-full flex-col items-center justify-center p-2">
<p className="mt-2 text-sm font-semibold text-slate-500 dark:text-slate-400">skipped</p>
</div>
))}
</div>
<div className="px-4 text-slate-500 md:px-6">{timeSince(response.updatedAt.toISOString())}</div>
</div>
</div>
))}
);
})}
</div>
</div>
);

View File

@@ -1,32 +1,55 @@
import Headline from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/Headline";
import { InboxIcon, Link, MessageSquareTextIcon } from "lucide-react";
import { FC } from "react";
import { ChatBubbleBottomCenterTextIcon, InboxStackIcon } from "@heroicons/react/24/solid";
import { Link } from "lucide-react";
import { FC, useMemo } from "react";
import { getPersonIdentifier } from "@formbricks/lib/person/util";
import { timeSince } from "@formbricks/lib/time";
import { TEnvironment } from "@formbricks/types/environment";
import { TSurveySummaryHiddenField } from "@formbricks/types/responses";
import { TResponse } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys";
import { PersonAvatar } from "@formbricks/ui/Avatars";
interface HiddenFieldsSummaryProps {
question: string;
survey: TSurvey;
responses: TResponse[];
environment: TEnvironment;
questionSummary: TSurveySummaryHiddenField;
}
const HiddenFieldsSummary: FC<HiddenFieldsSummaryProps> = ({ environment, questionSummary }) => {
const HiddenFieldsSummary: FC<HiddenFieldsSummaryProps> = ({ environment, responses, survey, question }) => {
const hiddenFieldResponses = useMemo(
() =>
survey.hiddenFields?.fieldIds?.map((question) => {
const questionResponses = responses
.filter((response) => question in response.data)
.map((r) => ({
id: r.id,
value: r.data[question],
updatedAt: r.updatedAt,
person: r.person,
}));
return {
question,
responses: questionResponses,
};
}),
[responses, survey.hiddenFields?.fieldIds]
);
return (
<div className="rounded-lg border border-slate-200 bg-slate-50 shadow-sm">
<div className="space-y-2 px-4 pb-5 pt-6 md:px-6">
<Headline headline={questionSummary.question} />
<Headline headline={question} />
<div className="flex space-x-2 text-xs font-semibold text-slate-600 md:text-sm">
<div className="flex items-center rounded-lg bg-slate-100 p-2 ">
<MessageSquareTextIcon className="mr-2 h-4 w-4" />
<ChatBubbleBottomCenterTextIcon className="mr-2 h-4 w-4" />
Hidden Field
</div>
<div className="flex items-center rounded-lg bg-slate-100 p-2 ">
<InboxIcon className="mr-2 h-4 w-4" />
{questionSummary.responseCount} {questionSummary.responseCount === 1 ? "Response" : "Responses"}
<InboxStackIcon className="mr-2 h-4 w-4" />
{hiddenFieldResponses?.find((q) => q.question === question)?.responses?.length} Responses
</div>
</div>
</div>
@@ -36,39 +59,44 @@ const HiddenFieldsSummary: FC<HiddenFieldsSummaryProps> = ({ environment, questi
<div className="col-span-2 pl-4 md:pl-6">Response</div>
<div className="px-4 md:px-6">Time</div>
</div>
{questionSummary.samples.map((response) => (
<div
key={response.value}
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 md:text-base">
<div className="pl-4 md:pl-6">
{response.person ? (
<Link
className="ph-no-capture group flex items-center"
href={`/environments/${environment.id}/people/${response.person.id}`}>
<div className="hidden md:flex">
<PersonAvatar personId={response.person.id} />
</div>
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
{getPersonIdentifier(response.person)}
</p>
</Link>
) : (
<div className="group flex items-center">
<div className="hidden md:flex">
<PersonAvatar personId="anonymous" />
</div>
<p className="break-all text-slate-600 md:ml-2">Anonymous</p>
{hiddenFieldResponses
?.find((q) => q.question === question)
?.responses.map((response) => {
const displayIdentifier = getPersonIdentifier(response.person!);
return (
<div
key={response.id}
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 md:text-base">
<div className="pl-4 md:pl-6">
{response.person ? (
<Link
className="ph-no-capture group flex items-center"
href={`/environments/${environment.id}/people/${response.person.id}`}>
<div className="hidden md:flex">
<PersonAvatar personId={response.person.id} />
</div>
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
{displayIdentifier}
</p>
</Link>
) : (
<div className="group flex items-center">
<div className="hidden md:flex">
<PersonAvatar personId="anonymous" />
</div>
<p className="break-all text-slate-600 md:ml-2">Anonymous</p>
</div>
)}
</div>
)}
</div>
<div className="ph-no-capture col-span-2 whitespace-pre-wrap pl-6 font-semibold">
{response.value}
</div>
<div className="px-4 text-slate-500 md:px-6">
{timeSince(new Date(response.updatedAt).toISOString())}
</div>
</div>
))}
<div className="ph-no-capture col-span-2 whitespace-pre-wrap pl-6 font-semibold">
{response.value}
</div>
<div className="px-4 text-slate-500 md:px-6">
{timeSince(response.updatedAt.toISOString())}
</div>
</div>
);
})}
</div>
</div>
);

View File

@@ -1,34 +1,131 @@
import Headline from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/Headline";
import { questionTypes } from "@/app/lib/questions";
import { InboxIcon } from "lucide-react";
import { InboxStackIcon } from "@heroicons/react/24/solid";
import Link from "next/link";
import { useMemo, useState } from "react";
import { getPersonIdentifier } from "@formbricks/lib/person/util";
import { TSurveySummaryMultipleChoice } from "@formbricks/types/responses";
import type { TSurveyQuestionSummary } from "@formbricks/types/surveys";
import {
TSurveyMultipleChoiceMultiQuestion,
TSurveyMultipleChoiceSingleQuestion,
TSurveyQuestionType,
} from "@formbricks/types/surveys";
import { PersonAvatar } from "@formbricks/ui/Avatars";
import { ProgressBar } from "@formbricks/ui/ProgressBar";
interface MultipleChoiceSummaryProps {
questionSummary: TSurveySummaryMultipleChoice;
questionSummary: TSurveyQuestionSummary<
TSurveyMultipleChoiceMultiQuestion | TSurveyMultipleChoiceSingleQuestion
>;
environmentId: string;
surveyType: string;
responsesPerPage: number;
}
interface ChoiceResult {
id: string;
label: string;
count: number;
percentage?: number;
otherValues?: {
value: string;
person: {
id: string;
name?: string;
email?: string;
};
}[];
}
export default function MultipleChoiceSummary({
questionSummary,
environmentId,
surveyType,
responsesPerPage,
}: MultipleChoiceSummaryProps) {
const isSingleChoice = questionSummary.question.type === TSurveyQuestionType.MultipleChoiceSingle;
const [otherDisplayCount, setOtherDisplayCount] = useState(responsesPerPage);
const questionTypeInfo = questionTypes.find((type) => type.id === questionSummary.question.type);
// sort by count and transform to array
const results = Object.values(questionSummary.choices).sort((a, b) => {
if (a.others) return 1; // Always put a after b if a has 'others'
if (b.others) return -1; // Always put b after a if b has 'others'
const results: ChoiceResult[] = useMemo(() => {
if (!("choices" in questionSummary.question)) return [];
// Sort by count
return b.count - a.count;
});
// build a dictionary of choices
const resultsDict: { [key: string]: ChoiceResult } = {};
for (const choice of questionSummary.question.choices) {
resultsDict[choice.label] = {
id: choice.id,
label: choice.label,
count: 0,
percentage: 0,
otherValues: [],
};
}
const addOtherChoice = (response, value) => {
for (const key in resultsDict) {
if (resultsDict[key].id === "other" && value !== "") {
const displayIdentifier = getPersonIdentifier(response.person);
resultsDict[key].otherValues?.push({
value,
person: {
id: response.personId,
email: typeof displayIdentifier === "string" ? displayIdentifier : undefined,
},
});
resultsDict[key].count += 1;
break;
}
}
};
// count the responses
for (const response of questionSummary.responses) {
// if single choice, only add responses that are in the choices
if (isSingleChoice && response.value.toString() in resultsDict) {
resultsDict[response.value.toString()].count += 1;
} else if (isSingleChoice) {
// if single choice and not in choices, add to other
addOtherChoice(response, response.value);
} else if (Array.isArray(response.value)) {
// if multi choice add all responses
for (const choice of response.value) {
if (choice in resultsDict) {
resultsDict[choice].count += 1;
} else {
// if multi choice and not in choices, add to other
addOtherChoice(response, choice);
}
}
}
}
// add the percentage
const total = questionSummary.responses.length;
for (const key of Object.keys(resultsDict)) {
if (resultsDict[key].count) {
resultsDict[key].percentage = resultsDict[key].count / total;
}
}
// sort by count and transform to array
const results = Object.values(resultsDict).sort((a: any, b: any) => {
if (a.id === "other") return 1; // Always put a after b if a's id is 'other'
if (b.id === "other") return -1; // Always put b after a if b's id is 'other'
// If neither id is 'other', compare counts
return b.count - a.count;
});
return results;
}, [questionSummary, isSingleChoice]);
const totalResponses = useMemo(() => {
let total = 0;
for (const result of results) {
total += result.count;
}
return total;
}, [results]);
return (
<div className=" rounded-lg border border-slate-200 bg-slate-50 shadow-sm">
@@ -41,8 +138,8 @@ export default function MultipleChoiceSummary({
Multiple-Choice {questionTypeInfo ? questionTypeInfo.label : "Unknown Question Type"} Question
</div>
<div className="flex items-center rounded-lg bg-slate-100 p-2">
<InboxIcon className="mr-2 h-4 w-4 " />
{questionSummary.responseCount} responses
<InboxStackIcon className="mr-2 h-4 w-4 " />
{totalResponses} responses
</div>
{!questionSummary.question.required && (
<div className="flex items-center rounded-lg bg-slate-100 p-2">Optional</div>
@@ -54,16 +151,16 @@ export default function MultipleChoiceSummary({
</div>
</div>
<div className="space-y-5 rounded-b-lg bg-white px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
{results.map((result, resultsIdx) => (
<div key={result.value}>
{results.map((result: any, resultsIdx) => (
<div key={result.label}>
<div className="text flex flex-col justify-between px-2 pb-2 sm:flex-row">
<div className="mr-8 flex w-full justify-between space-x-1 sm:justify-normal">
<p className="font-semibold text-slate-700">
{results.length - resultsIdx} - {result.value}
{results.length - resultsIdx} - {result.label}
</p>
<div>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
{Math.round(result.percentage)}%
{Math.round(result.percentage * 100)}%
</p>
</div>
</div>
@@ -71,15 +168,16 @@ export default function MultipleChoiceSummary({
{result.count} {result.count === 1 ? "response" : "responses"}
</p>
</div>
<ProgressBar barColor="bg-brand" progress={result.percentage / 100} />
{result.others && result.others.length > 0 && (
<ProgressBar barColor="bg-brand" progress={result.percentage} />
{result.otherValues.length > 0 && (
<div className="mt-4 rounded-lg border border-slate-200">
<div className="grid h-12 grid-cols-2 content-center rounded-t-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
<div className="col-span-1 pl-6 ">Specified &quot;Other&quot; answers</div>
<div className="col-span-1 pl-6 ">{surveyType === "web" && "User"}</div>
</div>
{result.others
.filter((otherValue) => otherValue.value !== "")
{result.otherValues
.filter((otherValue) => otherValue !== "")
.slice(0, otherDisplayCount)
.map((otherValue, idx) => (
<div key={idx}>
{surveyType === "link" && (
@@ -89,7 +187,7 @@ export default function MultipleChoiceSummary({
<span>{otherValue.value}</span>
</div>
)}
{surveyType === "web" && otherValue.person && (
{surveyType === "web" && (
<Link
href={
otherValue.person.id
@@ -109,6 +207,16 @@ export default function MultipleChoiceSummary({
)}
</div>
))}
{otherDisplayCount < result.otherValues.length && (
<div className="flex w-full items-center justify-center">
<button
type="button"
onClick={() => setOtherDisplayCount(otherDisplayCount + responsesPerPage)}
className="my-2 flex h-8 items-center justify-center rounded-lg border border-slate-300 bg-white px-3 text-sm text-slate-500 hover:bg-slate-100 hover:text-slate-700">
Show more
</button>
</div>
)}
</div>
)}
</div>

View File

@@ -1,17 +1,82 @@
import Headline from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/Headline";
import { questionTypes } from "@/app/lib/questions";
import { InboxIcon } from "lucide-react";
import { InboxStackIcon } from "@heroicons/react/24/solid";
import { useMemo } from "react";
import { TSurveySummaryNps } from "@formbricks/types/responses";
import type { TSurveyQuestionSummary } from "@formbricks/types/surveys";
import { TSurveyNPSQuestion } from "@formbricks/types/surveys";
import { HalfCircle, ProgressBar } from "@formbricks/ui/ProgressBar";
interface NPSSummaryProps {
questionSummary: TSurveySummaryNps;
questionSummary: TSurveyQuestionSummary<TSurveyNPSQuestion>;
}
interface Result {
promoters: number;
passives: number;
detractors: number;
total: number;
score: number;
}
interface ChoiceResult {
label: string;
count: number;
percentage: number;
}
export default function NPSSummary({ questionSummary }: NPSSummaryProps) {
const percentage = (count, total) => {
const result = count / total;
return result || 0;
};
const questionTypeInfo = questionTypes.find((type) => type.id === questionSummary.question.type);
const result: Result = useMemo(() => {
let data = {
promoters: 0,
passives: 0,
detractors: 0,
total: 0,
score: 0,
};
for (let response of questionSummary.responses) {
const value = response.value;
if (typeof value !== "number") continue;
data.total++;
if (value >= 9) {
data.promoters++;
} else if (value >= 7) {
data.passives++;
} else {
data.detractors++;
}
}
data.score = (percentage(data.promoters, data.total) - percentage(data.detractors, data.total)) * 100;
return data;
}, [questionSummary]);
const dismissed: ChoiceResult = useMemo(() => {
if (questionSummary.question.required) return { count: 0, label: "Dismissed", percentage: 0 };
const total = questionSummary.responses.length;
let count = 0;
for (const response of questionSummary.responses) {
if (!response.value) {
count += 1;
}
}
return {
count,
label: "Dismissed",
percentage: count / total,
};
}, [questionSummary]);
return (
<div className=" rounded-lg border border-slate-200 bg-slate-50 shadow-sm">
<div className="space-y-2 px-4 pb-5 pt-6 md:px-6">
@@ -23,8 +88,8 @@ export default function NPSSummary({ questionSummary }: NPSSummaryProps) {
{questionTypeInfo ? questionTypeInfo.label : "Unknown Question Type"}
</div>
<div className=" flex items-center rounded-lg bg-slate-100 p-2">
<InboxIcon className="mr-2 h-4 w-4 " />
{questionSummary.responseCount} responses
<InboxStackIcon className="mr-2 h-4 w-4 " />
{result.total} responses
</div>
{!questionSummary.question.required && (
<div className="flex items-center rounded-lg bg-slate-100 p-2">Optional</div>
@@ -39,41 +104,40 @@ export default function NPSSummary({ questionSummary }: NPSSummaryProps) {
<p className="font-semibold capitalize text-slate-700">{group}</p>
<div>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
{Math.round(questionSummary[group].percentage)}%
{Math.round(percentage(result[group], result.total) * 100)}%
</p>
</div>
</div>
<p className="flex w-32 items-end justify-end text-slate-600">
{questionSummary[group].count} {questionSummary[group].count === 1 ? "response" : "responses"}
{result[group]} {result[group] === 1 ? "response" : "responses"}
</p>
</div>
<ProgressBar barColor="bg-brand" progress={questionSummary[group].percentage / 100} />
<ProgressBar barColor="bg-brand" progress={percentage(result[group], result.total)} />
</div>
))}
</div>
{questionSummary.dismissed?.count > 0 && (
{dismissed.count > 0 && (
<div className="border-t bg-white px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
<div key={"dismissed"}>
<div key={dismissed.label}>
<div className="text flex justify-between px-2 pb-2">
<div className="mr-8 flex space-x-1">
<p className="font-semibold text-slate-700">dismissed</p>
<p className="font-semibold text-slate-700">{dismissed.label}</p>
<div>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
{Math.round(questionSummary.dismissed.percentage)}%
{Math.round(dismissed.percentage * 100)}%
</p>
</div>
</div>
<p className="flex w-32 items-end justify-end text-slate-600">
{questionSummary.dismissed.count}{" "}
{questionSummary.dismissed.count === 1 ? "response" : "responses"}
{dismissed.count} {dismissed.count === 1 ? "response" : "responses"}
</p>
</div>
<ProgressBar barColor="bg-slate-600" progress={questionSummary.dismissed.percentage / 100} />
<ProgressBar barColor="bg-slate-600" progress={dismissed.percentage} />
</div>
</div>
)}
<div className="flex justify-center rounded-b-lg bg-white pb-4 pt-4">
<HalfCircle value={questionSummary.score} />
<HalfCircle value={result.score} />
</div>
</div>
);

View File

@@ -1,20 +1,28 @@
import Headline from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/Headline";
import { questionTypes } from "@/app/lib/questions";
import { InboxIcon } from "lucide-react";
import { InboxStackIcon } from "@heroicons/react/24/solid";
import Link from "next/link";
import { useState } from "react";
import { getPersonIdentifier } from "@formbricks/lib/person/util";
import { timeSince } from "@formbricks/lib/time";
import { TSurveySummaryOpenText } from "@formbricks/types/responses";
import type { TSurveyQuestionSummary } from "@formbricks/types/surveys";
import { TSurveyOpenTextQuestion } from "@formbricks/types/surveys";
import { PersonAvatar } from "@formbricks/ui/Avatars";
interface OpenTextSummaryProps {
questionSummary: TSurveySummaryOpenText;
questionSummary: TSurveyQuestionSummary<TSurveyOpenTextQuestion>;
environmentId: string;
responsesPerPage: number;
}
export default function OpenTextSummary({ questionSummary, environmentId }: OpenTextSummaryProps) {
export default function OpenTextSummary({
questionSummary,
environmentId,
responsesPerPage,
}: OpenTextSummaryProps) {
const questionTypeInfo = questionTypes.find((type) => type.id === questionSummary.question.type);
const [displayCount, setDisplayCount] = useState(responsesPerPage);
return (
<div className="rounded-lg border border-slate-200 bg-slate-50 shadow-sm">
@@ -26,8 +34,8 @@ export default function OpenTextSummary({ questionSummary, environmentId }: Open
{questionTypeInfo ? questionTypeInfo.label : "Unknown Question Type"} Question
</div>
<div className=" flex items-center rounded-lg bg-slate-100 p-2">
<InboxIcon className="mr-2 h-4 w-4" />
{questionSummary.responseCount} Responses
<InboxStackIcon className="mr-2 h-4 w-4" />
{questionSummary.responses.length} Responses
</div>
{!questionSummary.question.required && (
<div className="flex items-center rounded-lg bg-slate-100 p-2">Optional</div>
@@ -40,39 +48,51 @@ export default function OpenTextSummary({ questionSummary, environmentId }: Open
<div className="col-span-2 pl-4 md:pl-6">Response</div>
<div className="px-4 md:px-6">Time</div>
</div>
{questionSummary.samples.map((response) => (
<div
key={response.id}
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 md:text-base">
<div className="pl-4 md:pl-6">
{response.person ? (
<Link
className="ph-no-capture group flex items-center"
href={`/environments/${environmentId}/people/${response.person.id}`}>
<div className="hidden md:flex">
<PersonAvatar personId={response.person.id} />
{questionSummary.responses.slice(0, displayCount).map((response) => {
const displayIdentifier = getPersonIdentifier(response.person!);
return (
<div
key={response.id}
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 md:text-base">
<div className="pl-4 md:pl-6">
{response.person ? (
<Link
className="ph-no-capture group flex items-center"
href={`/environments/${environmentId}/people/${response.person.id}`}>
<div className="hidden md:flex">
<PersonAvatar personId={response.person.id} />
</div>
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
{displayIdentifier}
</p>
</Link>
) : (
<div className="group flex items-center">
<div className="hidden md:flex">
<PersonAvatar personId="anonymous" />
</div>
<p className="break-all text-slate-600 md:ml-2">Anonymous</p>
</div>
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
{getPersonIdentifier(response.person)}
</p>
</Link>
) : (
<div className="group flex items-center">
<div className="hidden md:flex">
<PersonAvatar personId="anonymous" />
</div>
<p className="break-all text-slate-600 md:ml-2">Anonymous</p>
</div>
)}
</div>
<div className="ph-no-capture col-span-2 whitespace-pre-wrap pl-6 font-semibold">
{response.value}
</div>
<div className="px-4 text-slate-500 md:px-6">
{timeSince(new Date(response.updatedAt).toISOString())}
)}
</div>
<div className="ph-no-capture col-span-2 whitespace-pre-wrap pl-6 font-semibold">
{response.value}
</div>
<div className="px-4 text-slate-500 md:px-6">{timeSince(response.updatedAt.toISOString())}</div>
</div>
);
})}
{displayCount < questionSummary.responses.length && (
<div className="flex justify-center py-1">
<button
type="button"
onClick={() => setDisplayCount((prevCount) => prevCount + responsesPerPage)}
className="my-2 flex h-8 items-center justify-center rounded-lg border border-slate-300 bg-white px-3 text-sm text-slate-500 hover:bg-slate-100 hover:text-slate-700">
Show more
</button>
</div>
))}
)}
</div>
</div>
);

View File

@@ -1,20 +1,75 @@
import Headline from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/Headline";
import { questionTypes } from "@/app/lib/questions";
import { InboxIcon } from "lucide-react";
import { InboxStackIcon } from "@heroicons/react/24/solid";
import Image from "next/image";
import { useMemo } from "react";
import { TSurveySummaryPictureSelection } from "@formbricks/types/responses";
import type { TSurveyPictureSelectionQuestion, TSurveyQuestionSummary } from "@formbricks/types/surveys";
import { ProgressBar } from "@formbricks/ui/ProgressBar";
interface PictureChoiceSummaryProps {
questionSummary: TSurveySummaryPictureSelection;
questionSummary: TSurveyQuestionSummary<TSurveyPictureSelectionQuestion>;
}
interface ChoiceResult {
id: string;
imageUrl: string;
count: number;
percentage?: number;
}
export default function PictureChoiceSummary({ questionSummary }: PictureChoiceSummaryProps) {
const isMulti = questionSummary.question.allowMulti;
const questionTypeInfo = questionTypes.find((type) => type.id === questionSummary.question.type);
const results = questionSummary.choices.sort((a, b) => b.count - a.count);
const results: ChoiceResult[] = useMemo(() => {
if (!("choices" in questionSummary.question)) return [];
// build a dictionary of choices
const resultsDict: { [key: string]: ChoiceResult } = {};
for (const choice of questionSummary.question.choices) {
resultsDict[choice.id] = {
id: choice.id,
imageUrl: choice.imageUrl,
count: 0,
percentage: 0,
};
}
// count the responses
for (const response of questionSummary.responses) {
if (Array.isArray(response.value)) {
for (const choice of response.value) {
if (choice in resultsDict) {
resultsDict[choice].count += 1;
}
}
}
}
// add the percentage
const total = questionSummary.responses.length;
for (const key of Object.keys(resultsDict)) {
if (resultsDict[key].count) {
resultsDict[key].percentage = resultsDict[key].count / total;
}
}
// sort by count and transform to array
const results = Object.values(resultsDict).sort((a, b) => {
return b.count - a.count;
});
return results;
}, [questionSummary]);
const totalResponses = useMemo(() => {
let total = 0;
for (const result of results) {
total += result.count;
}
return total;
}, [results]);
return (
<div className=" rounded-lg border border-slate-200 bg-slate-50 shadow-sm">
@@ -27,8 +82,8 @@ export default function PictureChoiceSummary({ questionSummary }: PictureChoiceS
{questionTypeInfo ? questionTypeInfo.label : "Unknown Question Type"} Question
</div>
<div className="flex items-center rounded-lg bg-slate-100 p-2">
<InboxIcon className="mr-2 h-4 w-4 " />
{questionSummary.responseCount} responses
<InboxStackIcon className="mr-2 h-4 w-4 " />
{totalResponses} responses
</div>
<div className="flex items-center rounded-lg bg-slate-100 p-2">
{isMulti ? "Multi" : "Single"} Select
@@ -54,7 +109,7 @@ export default function PictureChoiceSummary({ questionSummary }: PictureChoiceS
</div>
<div className="self-end">
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
{Math.round(result.percentage)}%
{Math.round((result.percentage || 0) * 100)}%
</p>
</div>
</div>
@@ -62,7 +117,7 @@ export default function PictureChoiceSummary({ questionSummary }: PictureChoiceS
{result.count} {result.count === 1 ? "response" : "responses"}
</p>
</div>
<ProgressBar barColor="bg-brand" progress={result.percentage / 100 || 0} />
<ProgressBar barColor="bg-brand" progress={result.percentage || 0} />
</div>
))}
</div>

View File

@@ -1,19 +1,98 @@
import Headline from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/Headline";
import { questionTypes } from "@/app/lib/questions";
import { CircleSlash2, InboxIcon, SmileIcon, StarIcon } from "lucide-react";
import { InboxStackIcon } from "@heroicons/react/24/solid";
import { CircleSlash2, SmileIcon, StarIcon } from "lucide-react";
import { useMemo } from "react";
import { TSurveySummaryRating } from "@formbricks/types/responses";
import type { TSurveyQuestionSummary } from "@formbricks/types/surveys";
import { TSurveyQuestionType } from "@formbricks/types/surveys";
import { TSurveyRatingQuestion } from "@formbricks/types/surveys";
import { ProgressBar } from "@formbricks/ui/ProgressBar";
import { RatingResponse } from "@formbricks/ui/RatingResponse";
interface RatingSummaryProps {
questionSummary: TSurveySummaryRating;
questionSummary: TSurveyQuestionSummary<TSurveyRatingQuestion>;
}
interface ChoiceResult {
label: number | string;
count: number;
percentage: number;
}
export default function RatingSummary({ questionSummary }: RatingSummaryProps) {
const questionTypeInfo = questionTypes.find((type) => type.id === questionSummary.question.type);
const results: ChoiceResult[] = useMemo(() => {
if (questionSummary.question.type !== TSurveyQuestionType.Rating) return [];
// build a dictionary of choices
const resultsDict: { [key: string]: ChoiceResult } = {};
for (let i = 1; i <= questionSummary.question.range; i++) {
resultsDict[i.toString()] = {
count: 0,
label: i,
percentage: 0,
};
}
// count the responses
for (const response of questionSummary.responses) {
// if single choice, only add responses that are in the choices
if (!Array.isArray(response.value) && response.value in resultsDict) {
resultsDict[response.value].count += 1;
}
}
// add the percentage
const total = questionSummary.responses.length;
for (const key of Object.keys(resultsDict)) {
if (resultsDict[key].count) {
resultsDict[key].percentage = resultsDict[key].count / total;
}
}
// sort by count and transform to array
const results = Object.values(resultsDict).sort((a: any, b: any) => a.label - b.label);
return results;
}, [questionSummary]);
const dismissed: ChoiceResult = useMemo(() => {
if (questionSummary.question.required) return { count: 0, label: "Dismissed", percentage: 0 };
const total = questionSummary.responses.length;
let count = 0;
for (const response of questionSummary.responses) {
if (!response.value) {
count += 1;
}
}
return {
count,
label: "Dismissed",
percentage: count / total,
};
}, [questionSummary]);
const totalResponses = useMemo(() => {
let total = 0;
for (const result of results) {
total += result.count;
}
return total;
}, [results]);
const averageRating = useMemo(() => {
let total = 0;
let count = 0;
questionSummary.responses.forEach((response) => {
if (response.value && typeof response.value === "number") {
total += response.value;
count += 1;
}
});
const average = count > 0 ? total / count : 0;
return parseFloat(average.toFixed(2));
}, [questionSummary]);
const getIconBasedOnScale = useMemo(() => {
const scale = questionSummary.question.scale;
if (scale === "number") return <CircleSlash2 className="h-4 w-4" />;
@@ -32,12 +111,12 @@ export default function RatingSummary({ questionSummary }: RatingSummaryProps) {
{questionTypeInfo ? questionTypeInfo.label : "Unknown Question Type"} Question
</div>
<div className="flex items-center rounded-lg bg-slate-100 p-2">
<InboxIcon className="mr-2 h-4 w-4 " />
{questionSummary.responseCount} responses
<InboxStackIcon className="mr-2 h-4 w-4 " />
{totalResponses} responses
</div>
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
{getIconBasedOnScale}
<div>Overall: {questionSummary.average.toFixed(2)}</div>
<div>Overall: {averageRating}</div>
</div>
{!questionSummary.question.required && (
<div className="flex items-center rounded-lg bg-slate-100 p-2">Optional</div>
@@ -45,20 +124,20 @@ export default function RatingSummary({ questionSummary }: RatingSummaryProps) {
</div>
</div>
<div className="space-y-5 rounded-b-lg bg-white px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
{questionSummary.choices.map((result) => (
<div key={result.rating}>
{results.map((result: any) => (
<div key={result.label}>
<div className="text flex justify-between px-2 pb-2">
<div className="mr-8 flex items-center space-x-1">
<div className="mr-8 flex space-x-1">
<div className="font-semibold text-slate-700">
<RatingResponse
scale={questionSummary.question.scale}
answer={result.rating}
answer={result.label}
range={questionSummary.question.range}
/>
</div>
<div>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
{Math.round(result.percentage)}%
{Math.round(result.percentage * 100)}%
</p>
</div>
</div>
@@ -66,28 +145,27 @@ export default function RatingSummary({ questionSummary }: RatingSummaryProps) {
{result.count} {result.count === 1 ? "response" : "responses"}
</p>
</div>
<ProgressBar barColor="bg-brand" progress={result.percentage / 100} />
<ProgressBar barColor="bg-brand" progress={result.percentage} />
</div>
))}
</div>
{questionSummary.dismissed && questionSummary.dismissed.count > 0 && (
{dismissed.count > 0 && (
<div className="rounded-b-lg border-t bg-white px-6 pb-6 pt-4">
<div key="dismissed">
<div key={dismissed.label}>
<div className="text flex justify-between px-2 pb-2">
<div className="mr-8 flex space-x-1">
<p className="font-semibold text-slate-700">dismissed</p>
<p className="font-semibold text-slate-700">{dismissed.label}</p>
<div>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
{Math.round(questionSummary.dismissed.percentage)}%
{Math.round(dismissed.percentage * 100)}%
</p>
</div>
</div>
<p className="flex w-32 items-end justify-end text-slate-600">
{questionSummary.dismissed.count}{" "}
{questionSummary.dismissed.count === 1 ? "response" : "responses"}
{dismissed.count} {dismissed.count === 1 ? "response" : "responses"}
</p>
</div>
<ProgressBar barColor="bg-slate-600" progress={questionSummary.dismissed.percentage / 100} />
<ProgressBar barColor="bg-slate-600" progress={dismissed.percentage} />
</div>
</div>
)}

View File

@@ -1,18 +1,10 @@
"use client";
import { generateSingleUseIdAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/actions";
import {
ArrowLeftIcon,
BellRing,
BlocksIcon,
Code2Icon,
CopyIcon,
LinkIcon,
MailIcon,
RefreshCcw,
} from "lucide-react";
import { ArrowLeftIcon, CodeBracketIcon, EnvelopeIcon, LinkIcon } from "@heroicons/react/24/outline";
import { DocumentDuplicateIcon } from "@heroicons/react/24/solid";
import { BellRing, BlocksIcon, Code2Icon, RefreshCcw } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useRef, useState } from "react";
import toast from "react-hot-toast";
@@ -36,14 +28,13 @@ interface ShareEmbedSurveyProps {
user: TUser;
}
export default function ShareEmbedSurvey({ survey, open, setOpen, webAppUrl, user }: ShareEmbedSurveyProps) {
const router = useRouter();
const environmentId = survey.environmentId;
const isSingleUseLinkSurvey = survey.singleUse?.enabled ?? false;
const { email } = user;
const tabs = [
{ id: "email", label: "Embed in an Email", icon: MailIcon },
{ id: "webpage", label: "Embed in a Web Page", icon: Code2Icon },
{ id: "email", label: "Embed in an Email", icon: EnvelopeIcon },
{ id: "webpage", label: "Embed in a Web Page", icon: CodeBracketIcon },
{ id: "link", label: `${isSingleUseLinkSurvey ? "Single Use Links" : "Share the Link"}`, icon: LinkIcon },
];
@@ -82,9 +73,6 @@ export default function ShareEmbedSurvey({ survey, open, setOpen, webAppUrl, use
setActiveId(tabs[0].id);
setOpen(open);
setShowInitialPage(open); // Reset to initial page when modal opens
// fetch latest responses
router.refresh();
};
const handleInitialPageButton = () => {
@@ -121,7 +109,7 @@ export default function ShareEmbedSurvey({ survey, open, setOpen, webAppUrl, use
navigator.clipboard.writeText(surveyUrl);
toast.success("URL copied to clipboard!");
}}
EndIcon={CopyIcon}>
EndIcon={DocumentDuplicateIcon}>
Copy Link
</Button>
{survey.singleUse?.enabled && (

View File

@@ -1,6 +1,6 @@
"use client";
import { AlertCircleIcon, CheckCircle2Icon } from "lucide-react";
import { CheckCircleIcon, ExclamationCircleIcon } from "@heroicons/react/24/solid";
import { Clipboard } from "lucide-react";
import { toast } from "react-hot-toast";
@@ -32,7 +32,7 @@ export default function ShareSurveyResults({
{showPublishModal && surveyUrl ? (
<DialogContent className="flex flex-col rounded-2xl bg-white px-12 py-6">
<div className="flex flex-col items-center gap-y-6 text-center">
<CheckCircle2Icon className="h-20 w-20 text-slate-300" />
<CheckCircleIcon className="h-20 w-20 text-slate-300" />
<div>
<p className="text-lg font-medium text-slate-600">Your survey results are public!</p>
<p className="text-balanced mt-2 text-sm text-slate-500">
@@ -77,7 +77,7 @@ export default function ShareSurveyResults({
) : (
<DialogContent className="flex flex-col rounded-2xl bg-white p-8">
<div className="flex flex-col items-center gap-y-6 text-center">
<AlertCircleIcon className="h-20 w-20 text-slate-300" />
<ExclamationCircleIcon className="h-20 w-20 text-slate-300" />
<div>
<p className="text-lg font-medium text-slate-600">
You are about to release these survey results to the public.

View File

@@ -1,13 +1,164 @@
import { evaluateCondition } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/evaluateLogic";
import { TimerIcon } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { TSurveySummary } from "@formbricks/types/responses";
import { TResponse } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@formbricks/ui/Tooltip";
interface SummaryDropOffsProps {
dropOff: TSurveySummary["dropOff"];
survey: TSurvey;
responses: TResponse[];
displayCount: number;
}
export default function SummaryDropOffs({ dropOff }: SummaryDropOffsProps) {
export default function SummaryDropOffs({ responses, survey, displayCount }: SummaryDropOffsProps) {
const initialAvgTtc = useMemo(
() =>
survey.questions.reduce((acc, question) => {
acc[question.id] = 0;
return acc;
}, {}),
[survey.questions]
);
const [avgTtc, setAvgTtc] = useState(initialAvgTtc);
interface DropoffMetricsType {
dropoffCount: number[];
viewsCount: number[];
dropoffPercentage: number[];
}
const [dropoffMetrics, setDropoffMetrics] = useState<DropoffMetricsType>({
dropoffCount: [],
viewsCount: [],
dropoffPercentage: [],
});
const calculateMetrics = useCallback(() => {
let totalTtc = { ...initialAvgTtc };
let responseCounts = { ...initialAvgTtc };
let dropoffArr = new Array(survey.questions.length).fill(0);
let viewsArr = new Array(survey.questions.length).fill(0);
let dropoffPercentageArr = new Array(survey.questions.length).fill(0);
responses.forEach((response) => {
// Calculate total time-to-completion
Object.keys(avgTtc).forEach((questionId) => {
if (response.ttc && response.ttc[questionId]) {
totalTtc[questionId] += response.ttc[questionId];
responseCounts[questionId]++;
}
});
let currQuesIdx = 0;
while (currQuesIdx < survey.questions.length) {
const currQues = survey.questions[currQuesIdx];
if (!currQues) break;
if (!currQues.required) {
if (!response.data[currQues.id]) {
viewsArr[currQuesIdx]++;
if (currQuesIdx === survey.questions.length - 1 && !response.finished) {
dropoffArr[currQuesIdx]++;
break;
}
const questionHasCustomLogic = currQues.logic;
if (questionHasCustomLogic) {
let didLogicPass = false;
for (let logic of questionHasCustomLogic) {
if (!logic.destination) continue;
if (evaluateCondition(logic, response.data[currQues.id] ?? null)) {
didLogicPass = true;
currQuesIdx = survey.questions.findIndex((q) => q.id === logic.destination);
break;
}
}
if (!didLogicPass) currQuesIdx++;
} else {
currQuesIdx++;
}
continue;
}
}
if (
(response.data[currQues.id] === undefined && !response.finished) ||
(currQues.required && !response.data[currQues.id])
) {
dropoffArr[currQuesIdx]++;
viewsArr[currQuesIdx]++;
break;
}
viewsArr[currQuesIdx]++;
let nextQuesIdx = currQuesIdx + 1;
const questionHasCustomLogic = currQues.logic;
if (questionHasCustomLogic) {
for (let logic of questionHasCustomLogic) {
if (!logic.destination) continue;
if (evaluateCondition(logic, response.data[currQues.id])) {
nextQuesIdx = survey.questions.findIndex((q) => q.id === logic.destination);
break;
}
}
}
if (!response.data[survey.questions[nextQuesIdx]?.id] && !response.finished) {
dropoffArr[nextQuesIdx]++;
viewsArr[nextQuesIdx]++;
break;
}
currQuesIdx = nextQuesIdx;
}
});
// Calculate the average time for each question
Object.keys(totalTtc).forEach((questionId) => {
totalTtc[questionId] =
responseCounts[questionId] > 0 ? totalTtc[questionId] / responseCounts[questionId] : 0;
});
if (!survey.welcomeCard.enabled) {
dropoffArr[0] = displayCount - viewsArr[0];
if (viewsArr[0] > displayCount) dropoffPercentageArr[0] = 0;
dropoffPercentageArr[0] =
viewsArr[0] - displayCount >= 0 ? 0 : ((displayCount - viewsArr[0]) / displayCount) * 100 || 0;
viewsArr[0] = displayCount;
} else {
dropoffPercentageArr[0] = (dropoffArr[0] / viewsArr[0]) * 100;
}
for (let i = 1; i < survey.questions.length; i++) {
if (viewsArr[i] !== 0) {
dropoffPercentageArr[i] = (dropoffArr[i] / viewsArr[i]) * 100;
}
}
return {
newAvgTtc: totalTtc,
dropoffCount: dropoffArr,
viewsCount: viewsArr,
dropoffPercentage: dropoffPercentageArr,
};
}, [responses, survey.questions, displayCount, initialAvgTtc, avgTtc, survey.welcomeCard.enabled]);
useEffect(() => {
const { newAvgTtc, dropoffCount, viewsCount, dropoffPercentage } = calculateMetrics();
setAvgTtc(newAvgTtc);
setDropoffMetrics({ dropoffCount, viewsCount, dropoffPercentage });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [responses]);
return (
<div className="rounded-lg border border-slate-200 bg-slate-50 shadow-sm">
<div className="rounded-b-lg bg-white ">
@@ -28,18 +179,20 @@ export default function SummaryDropOffs({ dropOff }: SummaryDropOffsProps) {
<div className="px-4 text-center md:px-6">Views</div>
<div className="pr-6 text-center md:pl-6">Drop Offs</div>
</div>
{dropOff.map((quesDropOff) => (
{survey.questions.map((question, i) => (
<div
key={quesDropOff.questionId}
key={question.id}
className="grid grid-cols-6 items-center border-b border-slate-100 py-2 text-sm text-slate-800 md:text-base">
<div className="col-span-3 pl-4 md:pl-6">{quesDropOff.headline}</div>
<div className="col-span-3 pl-4 md:pl-6">{question.headline}</div>
<div className="whitespace-pre-wrap text-center font-semibold">
{quesDropOff.ttc > 0 ? (quesDropOff.ttc / 1000).toFixed(2) + "s" : "N/A"}
{avgTtc[question.id] !== undefined ? (avgTtc[question.id] / 1000).toFixed(2) + "s" : "N/A"}
</div>
<div className="whitespace-pre-wrap text-center font-semibold">
{dropoffMetrics.viewsCount[i]}
</div>
<div className="whitespace-pre-wrap text-center font-semibold">{quesDropOff.views}</div>
<div className=" pl-6 text-center md:px-6">
<span className="font-semibold">{quesDropOff.dropOffCount}</span>
<span>({Math.round(quesDropOff.dropOffPercentage)}%)</span>
<span className="font-semibold">{dropoffMetrics.dropoffCount[i]} </span>
<span>({Math.round(dropoffMetrics.dropoffPercentage[i])}%)</span>
</div>
</div>
))}

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