mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-02 03:15:05 -05:00
Compare commits
55 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0332a2efe3 | |||
| be8e461f55 | |||
| 722ee68b4c | |||
| e4078a3307 | |||
| 907a9dc563 | |||
| f6df94081d | |||
| 2436192995 | |||
| f54e2e032a | |||
| 1a28660dfd | |||
| f98a57582a | |||
| 0cc365261e | |||
| 6f78049c1f | |||
| 2f11aa6c14 | |||
| 09cb61ae1e | |||
| 65a152e518 | |||
| 92d88271d7 | |||
| a56c354e84 | |||
| 29a9b7e23e | |||
| 94a419249b | |||
| 12907c9061 | |||
| 0aa468f8f3 | |||
| 91447e1502 | |||
| 84ea14820a | |||
| 8fb472c37c | |||
| 6efb6d4e7b | |||
| 99da20f831 | |||
| 52d1dc9ed9 | |||
| 5633bb18ef | |||
| b2cb0ecff3 | |||
| 2089b339b4 | |||
| 2e83adc846 | |||
| 189cbcecd7 | |||
| 5aebde79e7 | |||
| 5cce4a1db4 | |||
| c6ff74f166 | |||
| e8aad9f469 | |||
| 455a061f35 | |||
| a9f35df278 | |||
| 82124a8b1c | |||
| f3f93faf1d | |||
| 57d117eb98 | |||
| d01b293a27 | |||
| a9f5289672 | |||
| 1df1419827 | |||
| f20a0d2ff7 | |||
| b9e5a6f9b9 | |||
| b2eaf1f6a3 | |||
| a873974f0d | |||
| 09974e1a10 | |||
| 45d5980527 | |||
| 73a25a412c | |||
| f0647ce240 | |||
| 7c09dd9d10 | |||
| c5bdbc89ca | |||
| 673832a7e1 |
+7
-1
@@ -56,11 +56,14 @@ SMTP_PASSWORD=smtpPassword
|
|||||||
|
|
||||||
# Uncomment the variables you would like to use and customize the values.
|
# 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 #
|
||||||
##############
|
##############
|
||||||
|
|
||||||
# S3 Storage is required for the file uplaod in serverless environments like Vercel
|
# S3 Storage is required for the file upload in serverless environments like Vercel
|
||||||
S3_ACCESS_KEY=
|
S3_ACCESS_KEY=
|
||||||
S3_SECRET_KEY=
|
S3_SECRET_KEY=
|
||||||
S3_REGION=
|
S3_REGION=
|
||||||
@@ -162,3 +165,6 @@ ENTERPRISE_LICENSE_KEY=
|
|||||||
|
|
||||||
# Ignore Rate Limiting across the Formbricks app
|
# Ignore Rate Limiting across the Formbricks app
|
||||||
# RATE_LIMITING_DISABLED=1
|
# RATE_LIMITING_DISABLED=1
|
||||||
|
|
||||||
|
# OpenTelemetry URL for tracing
|
||||||
|
# OPENTELEMETRY_LISTENER_URL=http://localhost:4318/v1/traces
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ runs:
|
|||||||
- name: Checkout repo
|
- name: Checkout repo
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- uses: ./.github/actions/dangerous-git-checkout
|
||||||
|
|
||||||
- name: Cache Build
|
- name: Cache Build
|
||||||
uses: actions/cache@v3
|
uses: actions/cache@v3
|
||||||
id: cache-build
|
id: cache-build
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
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 }}
|
||||||
|
OPENTELEMETRY_LISTENER_URL: ${{ vars.OPENTELEMETRY_LISTENER_URL }}
|
||||||
|
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
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
name: PR Update
|
name: PR Update
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request_target:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
merge_group:
|
merge_group:
|
||||||
@@ -30,7 +30,7 @@ jobs:
|
|||||||
- "!(**.md|.github/CODEOWNERS)"
|
- "!(**.md|.github/CODEOWNERS)"
|
||||||
|
|
||||||
test:
|
test:
|
||||||
name: Run Tests
|
name: Run Unit Tests
|
||||||
needs: [changes]
|
needs: [changes]
|
||||||
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
|
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
|
||||||
uses: ./.github/workflows/test.yml
|
uses: ./.github/workflows/test.yml
|
||||||
@@ -58,6 +58,7 @@ jobs:
|
|||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|
||||||
required:
|
required:
|
||||||
|
name: PR Check Summary
|
||||||
needs: [lint, test, build, e2e-test]
|
needs: [lint, test, build, e2e-test]
|
||||||
if: always()
|
if: always()
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ on:
|
|||||||
workflow_call:
|
workflow_call:
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
name: Tests
|
name: Unit Tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 15
|
timeout-minutes: 15
|
||||||
|
|
||||||
|
|||||||
Executable
+14
@@ -0,0 +1,14 @@
|
|||||||
|
#!/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"
|
||||||
Executable
+3
@@ -0,0 +1,3 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
echo "Rebooted Traefik on $KAMAL_HOSTS"
|
||||||
Executable
+51
@@ -0,0 +1,51 @@
|
|||||||
|
#!/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
|
||||||
Executable
+47
@@ -0,0 +1,47 @@
|
|||||||
|
#!/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 ]
|
||||||
Executable
+109
@@ -0,0 +1,109 @@
|
|||||||
|
#!/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
|
||||||
Executable
+3
@@ -0,0 +1,3 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
echo "Rebooting Traefik on $KAMAL_HOSTS..."
|
||||||
@@ -3,25 +3,25 @@ import {
|
|||||||
ClockIcon,
|
ClockIcon,
|
||||||
CogIcon,
|
CogIcon,
|
||||||
CreditCardIcon,
|
CreditCardIcon,
|
||||||
DocumentChartBarIcon,
|
FileBarChartIcon,
|
||||||
|
HelpCircleIcon,
|
||||||
HomeIcon,
|
HomeIcon,
|
||||||
QuestionMarkCircleIcon,
|
|
||||||
ScaleIcon,
|
ScaleIcon,
|
||||||
ShieldCheckIcon,
|
ShieldCheckIcon,
|
||||||
UserGroupIcon,
|
UsersIcon,
|
||||||
} from "@heroicons/react/24/outline";
|
} from "lucide-react";
|
||||||
|
|
||||||
const navigation = [
|
const navigation = [
|
||||||
{ name: "Home", href: "#", icon: HomeIcon, current: true },
|
{ name: "Home", href: "#", icon: HomeIcon, current: true },
|
||||||
{ name: "History", href: "#", icon: ClockIcon, current: false },
|
{ name: "History", href: "#", icon: ClockIcon, current: false },
|
||||||
{ name: "Balances", href: "#", icon: ScaleIcon, current: false },
|
{ name: "Balances", href: "#", icon: ScaleIcon, current: false },
|
||||||
{ name: "Cards", href: "#", icon: CreditCardIcon, current: false },
|
{ name: "Cards", href: "#", icon: CreditCardIcon, current: false },
|
||||||
{ name: "Recipients", href: "#", icon: UserGroupIcon, current: false },
|
{ name: "Recipients", href: "#", icon: UsersIcon, current: false },
|
||||||
{ name: "Reports", href: "#", icon: DocumentChartBarIcon, current: false },
|
{ name: "Reports", href: "#", icon: FileBarChartIcon, current: false },
|
||||||
];
|
];
|
||||||
const secondaryNavigation = [
|
const secondaryNavigation = [
|
||||||
{ name: "Settings", href: "#", icon: CogIcon },
|
{ name: "Settings", href: "#", icon: CogIcon },
|
||||||
{ name: "Help", href: "#", icon: QuestionMarkCircleIcon },
|
{ name: "Help", href: "#", icon: HelpCircleIcon },
|
||||||
{ name: "Privacy", href: "#", icon: ShieldCheckIcon },
|
{ name: "Privacy", href: "#", icon: ShieldCheckIcon },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -12,8 +12,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@formbricks/js": "workspace:*",
|
"@formbricks/js": "workspace:*",
|
||||||
"@heroicons/react": "^2.1.1",
|
"lucide-react": "^0.356.0",
|
||||||
"next": "14.1.1",
|
"next": "14.1.3",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-dom": "18.2.0"
|
"react-dom": "18.2.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { Fence } from "@/components/shared/Fence";
|
import { Fence } from "@/components/shared/Fence";
|
||||||
import {generateManagementApiMetadata} from "@/lib/utils"
|
import { generateManagementApiMetadata } from "@/lib/utils";
|
||||||
|
|
||||||
export const metadata = generateManagementApiMetadata("Surveys",["Fetch","Create","Update","Delete"])
|
export const metadata = generateManagementApiMetadata("Surveys", ["Fetch", "Create", "Update", "Delete"]);
|
||||||
|
|
||||||
#### Management API
|
#### Management API
|
||||||
|
|
||||||
# Surveys API
|
# Surveys API
|
||||||
|
|
||||||
This set of API can be used to
|
This set of API can be used to
|
||||||
|
|
||||||
- [List All Surveys](#list-all-surveys)
|
- [List All Surveys](#list-all-surveys)
|
||||||
- [Get Survey](#get-survey-by-id)
|
- [Get Survey](#get-survey-by-id)
|
||||||
- [Create Survey](#create-survey)
|
- [Create Survey](#create-survey)
|
||||||
@@ -22,8 +23,7 @@ This set of API can be used to
|
|||||||
|
|
||||||
<Row>
|
<Row>
|
||||||
<Col>
|
<Col>
|
||||||
|
Retrieve all the surveys you have for the environment with pagination.
|
||||||
Retrieve all the surveys you have for the environment.
|
|
||||||
|
|
||||||
### Mandatory Headers
|
### Mandatory Headers
|
||||||
|
|
||||||
@@ -33,14 +33,26 @@ This set of API can be used to
|
|||||||
</Property>
|
</Property>
|
||||||
</Properties>
|
</Properties>
|
||||||
|
|
||||||
|
### Query Parameters
|
||||||
|
<Properties>
|
||||||
|
<Property name="offset" type="number">
|
||||||
|
The number of surveys to skip before returning the results.
|
||||||
|
</Property>
|
||||||
|
|
||||||
|
<Property name="limit" type="number">
|
||||||
|
The number of surveys to return.
|
||||||
|
</Property>
|
||||||
|
</Properties>
|
||||||
|
|
||||||
</Col>
|
</Col>
|
||||||
<Col sticky>
|
<Col sticky>
|
||||||
|
|
||||||
<CodeGroup title="Request" tag="GET" label="/api/v1/management/surveys">
|
<CodeGroup title="Request" tag="GET" label="/api/v1/management/surveys">
|
||||||
|
|
||||||
```bash {{ title: 'cURL' }}
|
```bash {{ title: 'cURL' }}
|
||||||
|
|
||||||
curl --location \
|
curl --location \
|
||||||
'https://app.formbricks.com/api/v1/management/surveys' \
|
'https://app.formbricks.com/api/v1/management/surveys?offset=20&limit=10' \
|
||||||
--header \
|
--header \
|
||||||
'x-api-key: <your-api-key>'
|
'x-api-key: <your-api-key>'
|
||||||
```
|
```
|
||||||
@@ -403,7 +415,6 @@ This set of API can be used to
|
|||||||
```
|
```
|
||||||
</CodeGroup>
|
</CodeGroup>
|
||||||
|
|
||||||
|
|
||||||
</Col>
|
</Col>
|
||||||
<Col sticky>
|
<Col sticky>
|
||||||
|
|
||||||
@@ -453,7 +464,7 @@ This set of API can be used to
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
```json {{ title: '401 Not Authenticated' }}
|
```json {{ title: '401 Not Authenticated' }}
|
||||||
{
|
{
|
||||||
"code": "not_authenticated",
|
"code": "not_authenticated",
|
||||||
@@ -497,7 +508,6 @@ This set of API can be used to
|
|||||||
```
|
```
|
||||||
</CodeGroup>
|
</CodeGroup>
|
||||||
|
|
||||||
|
|
||||||
</Col>
|
</Col>
|
||||||
<Col sticky>
|
<Col sticky>
|
||||||
|
|
||||||
@@ -568,7 +578,7 @@ This set of API can be used to
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
```json {{ title: '401 Not Authenticated' }}
|
```json {{ title: '401 Not Authenticated' }}
|
||||||
{
|
{
|
||||||
"code": "not_authenticated",
|
"code": "not_authenticated",
|
||||||
@@ -585,7 +595,6 @@ This set of API can be used to
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
||||||
## Delete Survey by ID {{ tag: 'DELETE', label: '/api/v1/management/surveys/<survey-id>' }}
|
## Delete Survey by ID {{ tag: 'DELETE', label: '/api/v1/management/surveys/<survey-id>' }}
|
||||||
|
|
||||||
<Row>
|
<Row>
|
||||||
|
|||||||
@@ -99,7 +99,6 @@ if (typeof window !== "undefined") {
|
|||||||
formbricks.init({
|
formbricks.init({
|
||||||
environmentId: "<environment-id>",
|
environmentId: "<environment-id>",
|
||||||
apiHost: "<api-host>",
|
apiHost: "<api-host>",
|
||||||
debug: true, // remove when in production
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,7 +181,6 @@ useEffect(() => {
|
|||||||
formbricks.init({
|
formbricks.init({
|
||||||
environmentId: "<environment-id>",
|
environmentId: "<environment-id>",
|
||||||
apiHost: "<api-host>",
|
apiHost: "<api-host>",
|
||||||
debug: true, // remove when in production
|
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -232,7 +230,6 @@ if (typeof window !== "undefined") {
|
|||||||
formbricks.init({
|
formbricks.init({
|
||||||
environmentId: "<environment-id>",
|
environmentId: "<environment-id>",
|
||||||
apiHost: "<api-host>",
|
apiHost: "<api-host>",
|
||||||
debug: true, // remove when in production
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,14 +266,6 @@ Refer to our [Example NextJS Pages Directory project](https://github.com/formbri
|
|||||||
</Property>
|
</Property>
|
||||||
</Properties>
|
</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?
|
### What are we doing here?
|
||||||
|
|
||||||
First we need to initialize the Formbricks SDK, making sure it only runs on the client side.
|
First we need to initialize the Formbricks SDK, making sure it only runs on the client side.
|
||||||
@@ -358,14 +347,6 @@ router.afterEach((to, from) => {
|
|||||||
</Property>
|
</Property>
|
||||||
</Properties>
|
</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!
|
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
|
## Validate your setup
|
||||||
|
|||||||
@@ -76,6 +76,27 @@ GOOGLE_CLIENT_SECRET=your-client-secret-here
|
|||||||
- Navigate to your Docker setup directory where your `docker-compose.yml` file is located.
|
- 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:
|
- 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
|
## 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.
|
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.
|
||||||
@@ -115,52 +136,54 @@ OIDC_SIGNING_ALGORITHM=HS256
|
|||||||
|
|
||||||
These variables can be provided at the runtime i.e. in your docker-compose file.
|
These variables can be provided at the runtime i.e. in your docker-compose file.
|
||||||
|
|
||||||
| Variable | Description | Required | Default |
|
| Variable | Description | Required | Default |
|
||||||
| --------------------------- | -------------------------------------------------------------------------------------------- | ------------------------------------------------------- | ----------------------- |
|
|-----------------------------|----------------------------------------------------------------------------------------------|---------------------------------------------------------|---------------------------|
|
||||||
| WEBAPP_URL | Base URL of the site. | required | `http://localhost:3000` |
|
| WEBAPP_URL | Base URL of the site. | required | `http://localhost:3000` |
|
||||||
| DATABASE_URL | Database URL with credentials. | required | |
|
| DATABASE_URL | Database URL with credentials. | required | |
|
||||||
| NEXTAUTH_SECRET | Secret for NextAuth, used for session signing and encryption. | required | (Generated by the user) |
|
| 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) |
|
| 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` |
|
| 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) | |
|
| UPLOADS_DIR | Local directory for storing uploads. | optional | `./uploads` |
|
||||||
| S3_SECRET_KEY | Secret key for S3. | optional (required if S3 is enabled) | |
|
| S3_ACCESS_KEY | Access key for S3. | optional | (resolved by the AWS SDK) |
|
||||||
| S3_REGION | Region for S3. | optional (required if S3 is enabled) | |
|
| S3_SECRET_KEY | Secret key for S3. | optional | (resolved by the AWS SDK) |
|
||||||
| S3_BUCKET | Bucket name for S3. | optional (required if S3 is enabled) | |
|
| S3_REGION | Region for S3. | optional | (resolved by the AWS SDK) |
|
||||||
| S3_ENDPOINT | Endpoint for S3. | optional (required if S3 is enabled) | |
|
| S3_BUCKET | Bucket name for S3. | optional (required if S3 is enabled) | |
|
||||||
| PRIVACY_URL | URL for privacy policy. | optional | |
|
| S3_ENDPOINT | Endpoint for S3. | optional | (resolved by the AWS SDK) |
|
||||||
| TERMS_URL | URL for terms of service. | optional | |
|
| PRIVACY_URL | URL for privacy policy. | optional | |
|
||||||
| IMPRINT_URL | URL for imprint. | optional | |
|
| TERMS_URL | URL for terms of service. | optional | |
|
||||||
| SIGNUP_DISABLED | Disables the ability for new users to create an account if set to `1`. | optional | |
|
| IMPRINT_URL | URL for imprint. | optional | |
|
||||||
| EMAIL_AUTH_DISABLED | Disables the ability for users to signup or login via email and password if set to `1`. | optional | |
|
| SIGNUP_DISABLED | Disables the ability for new users to create an account if set to `1`. | optional | |
|
||||||
| PASSWORD_RESET_DISABLED | Disables password reset functionality 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 | |
|
||||||
| EMAIL_VERIFICATION_DISABLED | Disables email verification if set to `1`. | optional | |
|
| PASSWORD_RESET_DISABLED | Disables password reset functionality if set to `1`. | optional | |
|
||||||
| RATE_LIMITING_DISABLED | Disables rate limiting if set to `1`. | optional | |
|
| EMAIL_VERIFICATION_DISABLED | Disables email verification if set to `1`. | optional | |
|
||||||
| INVITE_DISABLED | Disables the ability for invited users to create an account if set to `1`. | optional | |
|
| RATE_LIMITING_DISABLED | Disables rate limiting if set to `1`. | optional | |
|
||||||
| MAIL_FROM | Email address to send emails from. | optional (required if email services are to be enabled) | |
|
| INVITE_DISABLED | Disables the ability for invited users to create an account if set to `1`. | optional | |
|
||||||
| SMTP_HOST | Host URL of your SMTP server. | optional (required if email services are to be enabled) | |
|
| MAIL_FROM | Email address to send emails from. | 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_HOST | Host URL 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_PORT | Host Port of 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_USER | Username 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) | |
|
| SMTP_PASSWORD | Password for your SMTP Server. | optional (required if email services are to be enabled) | |
|
||||||
| GITHUB_ID | Client ID for GitHub. | optional (required if GitHub auth is 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_SECRET | Secret for GitHub. | optional (required if GitHub auth is enabled) | |
|
| GITHUB_ID | Client ID for GitHub. | optional (required if GitHub auth is enabled) | |
|
||||||
| GOOGLE_CLIENT_ID | Client ID for Google. | optional (required if Google auth is enabled) | |
|
| GITHUB_SECRET | Secret for GitHub. | optional (required if GitHub auth is enabled) | |
|
||||||
| GOOGLE_CLIENT_SECRET | Secret for Google. | optional (required if Google auth is enabled) | |
|
| GOOGLE_CLIENT_ID | Client ID for Google. | optional (required if Google auth is enabled) | |
|
||||||
| CRON_SECRET | API Secret for running cron jobs. | optional | |
|
| GOOGLE_CLIENT_SECRET | Secret for Google. | optional (required if Google auth is enabled) | |
|
||||||
| STRIPE_SECRET_KEY | Secret key for Stripe integration. | optional | |
|
| CRON_SECRET | API Secret for running cron jobs. | optional | |
|
||||||
| STRIPE_WEBHOOK_SECRET | Webhook secret for Stripe integration. | optional | |
|
| STRIPE_SECRET_KEY | Secret key for Stripe integration. | optional | |
|
||||||
| TELEMETRY_DISABLED | Disables telemetry if set to `1`. | optional | |
|
| STRIPE_WEBHOOK_SECRET | Webhook secret for Stripe integration. | optional | |
|
||||||
| INSTANCE_ID | Instance ID for Formbricks Cloud to be sent to Telemetry. | optional | |
|
| TELEMETRY_DISABLED | Disables telemetry if set to `1`. | optional | |
|
||||||
| INTERNAL_SECRET | Internal Secret (Currently we overwrite the value with a random value). | optional | |
|
| INSTANCE_ID | Instance ID for Formbricks Cloud to be sent to Telemetry. | optional | |
|
||||||
| DEFAULT_BRAND_COLOR | Default brand color for your app (Can be overwritten from the UI as well). | optional | `#64748b` |
|
| INTERNAL_SECRET | Internal Secret (Currently we overwrite the value with a random value). | optional | |
|
||||||
| DEFAULT_TEAM_ID | Automatically assign new users to a specific team when joining | optional | |
|
| DEFAULT_BRAND_COLOR | Default brand color for your app (Can be overwritten from the UI as well). | optional | `#64748b` |
|
||||||
| DEFAULT_TEAM_ROLE | Role of the user in the default team. | optional | `admin` |
|
| DEFAULT_TEAM_ID | Automatically assign new users to a specific team when joining | optional | |
|
||||||
| ONBOARDING_DISABLED | Disables onboarding for new users if set to `1` | optional | |
|
| DEFAULT_TEAM_ROLE | Role of the user in the default team. | optional | `admin` |
|
||||||
| OIDC_DISPLAY_NAME | Display name for Custom OpenID Connect Provider | optional | |
|
| ONBOARDING_DISABLED | Disables onboarding for new users if set to `1` | optional | |
|
||||||
| OIDC_CLIENT_ID | Client ID for Custom OpenID Connect Provider | optional (required if OIDC auth is enabled) | |
|
| OIDC_DISPLAY_NAME | Display name for Custom OpenID Connect Provider | optional | |
|
||||||
| OIDC_CLIENT_SECRET | Secret for Custom OpenID Connect Provider | optional (required if OIDC auth is enabled) | |
|
| OIDC_CLIENT_ID | Client ID 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_CLIENT_SECRET | Secret for Custom OpenID Connect Provider | optional (required if OIDC auth is enabled) | |
|
||||||
| OIDC_SIGNING_ALGORITHM | Signing Algorithm for Custom OpenID Connect Provider | optional | `RS256` |
|
| 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` |
|
||||||
|
| OPENTELEMETRY_LISTENER_URL | URL for OpenTelemetry listener inside Formbricks. | optional | | |
|
||||||
|
|
||||||
## Build-time Variables
|
## Build-time Variables
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { PlusIcon, TrashIcon } from "@heroicons/react/24/solid";
|
import { PlusIcon, TrashIcon } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import { Button } from "@formbricks/ui/Button";
|
import { Button } from "@formbricks/ui/Button";
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { CursorArrowRaysIcon } from "@heroicons/react/24/solid";
|
import { MousePointerClickIcon } from "lucide-react";
|
||||||
|
|
||||||
import { Button } from "@formbricks/ui/Button";
|
import { Button } from "@formbricks/ui/Button";
|
||||||
import { Input } from "@formbricks/ui/Input";
|
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="p-4 sm:p-6">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<div className="h-6 w-6 text-slate-500">
|
<div className="h-6 w-6 text-slate-500">
|
||||||
<CursorArrowRaysIcon />
|
<MousePointerClickIcon className="h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-lg font-medium text-slate-700 dark:text-slate-300">Add Action</div>
|
<div className="text-lg font-medium text-slate-700 dark:text-slate-300">Add Action</div>
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import * as DOMPurify from "dompurify";
|
|
||||||
|
|
||||||
export default function HtmlBody({ htmlString, questionId }: { htmlString: string; questionId: string }) {
|
export default function HtmlBody({ htmlString, questionId }: { htmlString: string; questionId: string }) {
|
||||||
return (
|
return (
|
||||||
<label
|
<label
|
||||||
htmlFor={questionId}
|
htmlFor={questionId}
|
||||||
className="fb-block fb-font-normal fb-leading-6 text-sm text-slate-500 dark:text-slate-300"
|
className="fb-block fb-font-normal fb-leading-6 text-sm text-slate-500 dark:text-slate-300"
|
||||||
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(htmlString) }}></label>
|
dangerouslySetInnerHTML={{ __html: htmlString }}></label>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import NILogoLight from "@/images/clients/niLogoWhite.svg";
|
|||||||
import OptimoleLogo from "@/images/clients/optimole-logo.svg";
|
import OptimoleLogo from "@/images/clients/optimole-logo.svg";
|
||||||
import ThemeisleLogo from "@/images/clients/themeisle-logo.webp";
|
import ThemeisleLogo from "@/images/clients/themeisle-logo.webp";
|
||||||
import AnimationFallback from "@/public/animations/opensource-xm-platform-formbricks-fallback.png";
|
import AnimationFallback from "@/public/animations/opensource-xm-platform-formbricks-fallback.png";
|
||||||
import { ShieldCheckIcon, StarIcon } from "@heroicons/react/24/outline";
|
import { ShieldCheckIcon, StarIcon } from "lucide-react";
|
||||||
import { usePlausible } from "next-plausible";
|
import { usePlausible } from "next-plausible";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ArrowUpIcon } from "@heroicons/react/24/solid";
|
|
||||||
import throttle from "lodash/throttle";
|
import throttle from "lodash/throttle";
|
||||||
|
import { ArrowUpIcon } from "lucide-react";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
import { Button } from "@formbricks/ui/Button";
|
import { Button } from "@formbricks/ui/Button";
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import DemoPreview from "@/components/dummyUI/DemoPreview";
|
import DemoPreview from "@/components/dummyUI/DemoPreview";
|
||||||
import DashboardMockupDark from "@/images/dashboard-mockup-dark.png";
|
import DashboardMockupDark from "@/images/dashboard-mockup-dark.png";
|
||||||
import DashboardMockup from "@/images/dashboard-mockup.png";
|
import DashboardMockup from "@/images/dashboard-mockup.png";
|
||||||
import { CursorArrowRaysIcon } from "@heroicons/react/24/solid";
|
import { MousePointerClickIcon } from "lucide-react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useState } from "react";
|
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="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">
|
<div className="flex h-40 items-center justify-center">
|
||||||
<Button variant="primary">
|
<Button variant="primary">
|
||||||
<CursorArrowRaysIcon className="mr-2 h-5 w-5 text-white" />
|
<MousePointerClickIcon className="mr-2 h-5 w-5 text-white" />
|
||||||
Add Action
|
Add Action
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/24/solid";
|
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
import { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
interface APICallProps {
|
interface APICallProps {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import GitHubMarkWhite from "@/images/github-mark-white.svg";
|
import GitHubMarkWhite from "@/images/github-mark-white.svg";
|
||||||
import GitHubMarkDark from "@/images/github-mark.svg";
|
import GitHubMarkDark from "@/images/github-mark.svg";
|
||||||
import { Popover, Transition } from "@headlessui/react";
|
import { Popover, Transition } from "@headlessui/react";
|
||||||
import { Bars3Icon, ChevronDownIcon, ChevronRightIcon, XMarkIcon } from "@heroicons/react/24/outline";
|
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
import { ChevronDownIcon, ChevronRightIcon, MenuIcon, XIcon } from "lucide-react";
|
||||||
import { usePlausible } from "next-plausible";
|
import { usePlausible } from "next-plausible";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
@@ -136,7 +136,7 @@ export default function Header() {
|
|||||||
<div className="-my-2 -mr-2 md:hidden">
|
<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">
|
<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>
|
<span className="sr-only">Open menu</span>
|
||||||
<Bars3Icon className="h-6 w-6" aria-hidden="true" />
|
<MenuIcon className="h-6 w-6" aria-hidden="true" />
|
||||||
</Popover.Button>
|
</Popover.Button>
|
||||||
</div>
|
</div>
|
||||||
<Popover.Group as="nav" className="hidden space-x-6 md:flex lg:space-x-10">
|
<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">
|
<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">
|
<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>
|
<span className="sr-only">Close menu</span>
|
||||||
<XMarkIcon className="h-6 w-6" aria-hidden="true" />
|
<XIcon className="h-6 w-6" aria-hidden="true" />
|
||||||
</Popover.Button>
|
</Popover.Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { DocumentDuplicateIcon } from "@heroicons/react/24/outline";
|
import { CopyIcon } from "lucide-react";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
|
|
||||||
import { Button } from "@formbricks/ui/Button";
|
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 ">
|
<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>
|
<p>npm install @formbricks/react</p>
|
||||||
<button onClick={() => navigator.clipboard.writeText("npm install @formbricks/react")}>
|
<button onClick={() => navigator.clipboard.writeText("npm install @formbricks/react")}>
|
||||||
<DocumentDuplicateIcon className="h-8 w-8" />
|
<CopyIcon className="h-8 w-8" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { CheckIcon, XMarkIcon } from "@heroicons/react/24/outline";
|
import { CheckIcon, XIcon } from "lucide-react";
|
||||||
|
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@formbricks/ui/Tooltip";
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@formbricks/ui/Tooltip";
|
||||||
|
|
||||||
@@ -54,13 +54,9 @@ export const PricingTable = ({ leadRow, pricing, endRow }) => {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
) : feature.free ? (
|
) : feature.free ? (
|
||||||
<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=" 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" />
|
||||||
<CheckIcon className=" text-green-500 dark:text-green-300" />
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<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">
|
<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" />
|
||||||
<XMarkIcon className="text-red-500 dark:text-red-600" />
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-1/3 items-center justify-center text-center text-sm text-slate-800 dark:text-slate-100">
|
<div className="flex w-1/3 items-center justify-center text-center text-sm text-slate-800 dark:text-slate-100">
|
||||||
@@ -78,13 +74,9 @@ export const PricingTable = ({ leadRow, pricing, endRow }) => {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
) : feature.paid ? (
|
) : feature.paid ? (
|
||||||
<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=" 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" />
|
||||||
<CheckIcon className="text-green-500 dark:text-green-300" />
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<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">
|
<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" />
|
||||||
<XMarkIcon className="text-red-500 dark:text-red-600" />
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import LFGLuigi from "@/images/blog/lfg-luigi-200px.webp";
|
import LFGLuigi from "@/images/blog/lfg-luigi-200px.webp";
|
||||||
import { XMarkIcon } from "@heroicons/react/24/solid";
|
import { XIcon } from "lucide-react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
|
|
||||||
@@ -70,7 +70,7 @@ const SlideInBanner: React.FC<Props> = ({ delay = 5000, scrollPercentage = 10, U
|
|||||||
setTimeout(() => setIsDismissed(true), 500);
|
setTimeout(() => setIsDismissed(true), 500);
|
||||||
}}
|
}}
|
||||||
className="rounded-full p-2 hover:bg-slate-600 hover:bg-opacity-30">
|
className="rounded-full p-2 hover:bg-slate-600 hover:bg-opacity-30">
|
||||||
<XMarkIcon className="h-6 w-6" />
|
<XIcon className="h-6 w-6" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { DocumentDuplicateIcon } from "@heroicons/react/24/outline";
|
import { CopyIcon } from "lucide-react";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
|
|
||||||
import { Button } from "@formbricks/ui/Button";
|
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 ">
|
<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>
|
<p>npm install @formbricks/react</p>
|
||||||
<button onClick={() => navigator.clipboard.writeText("npm install @formbricks/react")}>
|
<button onClick={() => navigator.clipboard.writeText("npm install @formbricks/react")}>
|
||||||
<DocumentDuplicateIcon className="h-8 w-8" />
|
<CopyIcon className="h-8 w-8" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,42 +1,35 @@
|
|||||||
import {
|
import { BlocksIcon, BoxIcon, LockIcon, SwatchBookIcon, TerminalIcon, UsersIcon } from "lucide-react";
|
||||||
CommandLineIcon,
|
|
||||||
CubeTransparentIcon,
|
|
||||||
SquaresPlusIcon,
|
|
||||||
SwatchIcon,
|
|
||||||
UserGroupIcon,
|
|
||||||
UsersIcon,
|
|
||||||
} from "@heroicons/react/24/outline";
|
|
||||||
|
|
||||||
const features = [
|
const features = [
|
||||||
{
|
{
|
||||||
name: "Futureproof",
|
name: "Futureproof",
|
||||||
description: "Form needs change. With Formbricks you’ll avoid island solutions right from the start.",
|
description: "Form needs change. With Formbricks you’ll avoid island solutions right from the start.",
|
||||||
icon: CubeTransparentIcon,
|
icon: BoxIcon,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Privacy by design",
|
name: "Privacy by design",
|
||||||
description: "Self-host the entire product and fly through privacy compliance reviews.",
|
description: "Self-host the entire product and fly through privacy compliance reviews.",
|
||||||
icon: UsersIcon,
|
icon: LockIcon,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Community driven",
|
name: "Community driven",
|
||||||
description: "We're building for you. If you need something specific, we’re happy to build it!",
|
description: "We're building for you. If you need something specific, we’re happy to build it!",
|
||||||
icon: UserGroupIcon,
|
icon: UsersIcon,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Great DX",
|
name: "Great DX",
|
||||||
description: "We love a solid developer experience. We felt your pain and do our best to avoid it.",
|
description: "We love a solid developer experience. We felt your pain and do our best to avoid it.",
|
||||||
icon: CommandLineIcon,
|
icon: TerminalIcon,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Customizable",
|
name: "Customizable",
|
||||||
description: "We have to build opinionated. If it doesn't suit your need, just change it up.",
|
description: "We have to build opinionated. If it doesn't suit your need, just change it up.",
|
||||||
icon: SwatchIcon,
|
icon: SwatchBookIcon,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Extendable",
|
name: "Extendable",
|
||||||
description: "Even though we try, we cannot build every single integration. With Formbricks, you can.",
|
description: "Even though we try, we cannot build every single integration. With Formbricks, you can.",
|
||||||
icon: SquaresPlusIcon,
|
icon: BlocksIcon,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -1,50 +1,51 @@
|
|||||||
import { slugifyWithCounter } from "@sindresorhus/slugify";
|
import { slugifyWithCounter } from '@sindresorhus/slugify'
|
||||||
import glob from "fast-glob";
|
import glob from 'fast-glob'
|
||||||
import * as fs from "fs";
|
import * as fs from 'fs'
|
||||||
import { toString } from "mdast-util-to-string";
|
import { toString } from 'mdast-util-to-string'
|
||||||
import * as path from "path";
|
import * as path from 'path'
|
||||||
import { remark } from "remark";
|
import { remark } from 'remark'
|
||||||
import remarkMdx from "remark-mdx";
|
import remarkMdx from 'remark-mdx'
|
||||||
import { createLoader } from "simple-functional-loader";
|
import { createLoader } from 'simple-functional-loader'
|
||||||
import { filter } from "unist-util-filter";
|
import { filter } from 'unist-util-filter'
|
||||||
import { SKIP, visit } from "unist-util-visit";
|
import { SKIP, visit } from 'unist-util-visit'
|
||||||
import * as url from "url";
|
import * as url from 'url'
|
||||||
|
|
||||||
const __filename = url.fileURLToPath(import.meta.url);
|
const __filename = url.fileURLToPath(import.meta.url)
|
||||||
const processor = remark().use(remarkMdx).use(extractSections);
|
const processor = remark().use(remarkMdx).use(extractSections)
|
||||||
const slugify = slugifyWithCounter();
|
const slugify = slugifyWithCounter()
|
||||||
|
|
||||||
function isObjectExpression(node) {
|
function isObjectExpression(node) {
|
||||||
return (
|
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) {
|
function excludeObjectExpressions(tree) {
|
||||||
return filter(tree, (node) => !isObjectExpression(node));
|
return filter(tree, (node) => !isObjectExpression(node))
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractSections() {
|
function extractSections() {
|
||||||
return (tree, { sections }) => {
|
return (tree, { sections }) => {
|
||||||
slugify.reset();
|
slugify.reset()
|
||||||
|
|
||||||
visit(tree, (node) => {
|
visit(tree, (node) => {
|
||||||
if (node.type === "heading" || node.type === "paragraph") {
|
if (node.type === 'heading' || node.type === 'paragraph') {
|
||||||
let content = toString(excludeObjectExpressions(node));
|
let content = toString(excludeObjectExpressions(node))
|
||||||
if (node.type === "heading" && node.depth <= 2) {
|
if (node.type === 'heading' && node.depth <= 2) {
|
||||||
let hash = node.depth === 1 ? null : slugify(content);
|
let hash = node.depth === 1 ? null : slugify(content)
|
||||||
sections.push([content, hash, []]);
|
sections.push([content, hash, []])
|
||||||
} else {
|
} else {
|
||||||
sections.at(-1)?.[2].push(content);
|
sections.at(-1)?.[2].push(content)
|
||||||
}
|
}
|
||||||
return SKIP;
|
return SKIP
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function (nextConfig = {}) {
|
export default function Search(nextConfig = {}) {
|
||||||
let cache = new Map();
|
let cache = new Map()
|
||||||
|
|
||||||
return Object.assign({}, nextConfig, {
|
return Object.assign({}, nextConfig, {
|
||||||
webpack(config, options) {
|
webpack(config, options) {
|
||||||
@@ -52,26 +53,26 @@ export default function (nextConfig = {}) {
|
|||||||
test: __filename,
|
test: __filename,
|
||||||
use: [
|
use: [
|
||||||
createLoader(function () {
|
createLoader(function () {
|
||||||
let appDir = path.resolve("./app");
|
let appDir = path.resolve('./src/app')
|
||||||
this.addContextDependency(appDir);
|
this.addContextDependency(appDir)
|
||||||
|
|
||||||
let files = glob.sync("**/*.mdx", { cwd: appDir });
|
let files = glob.sync('**/*.mdx', { cwd: appDir })
|
||||||
let data = files.map((file) => {
|
let data = files.map((file) => {
|
||||||
let url = "/" + file.replace(/(^|\/)page\.mdx$/, "");
|
let url = '/' + file.replace(/(^|\/)page\.mdx$/, '')
|
||||||
let mdx = fs.readFileSync(path.join(appDir, file), "utf8");
|
let mdx = fs.readFileSync(path.join(appDir, file), 'utf8')
|
||||||
|
|
||||||
let sections = [];
|
let sections = []
|
||||||
|
|
||||||
if (cache.get(file)?.[0] === mdx) {
|
if (cache.get(file)?.[0] === mdx) {
|
||||||
sections = cache.get(file)[1];
|
sections = cache.get(file)[1]
|
||||||
} else {
|
} else {
|
||||||
let vfile = { value: mdx, sections };
|
let vfile = { value: mdx, sections }
|
||||||
processor.runSync(processor.parse(vfile), vfile);
|
processor.runSync(processor.parse(vfile), vfile)
|
||||||
cache.set(file, [mdx, sections]);
|
cache.set(file, [mdx, sections])
|
||||||
}
|
}
|
||||||
|
|
||||||
return { url, sections };
|
return { url, sections }
|
||||||
});
|
})
|
||||||
|
|
||||||
// When this file is imported within the application
|
// When this file is imported within the application
|
||||||
// the following module is loaded:
|
// the following module is loaded:
|
||||||
@@ -119,16 +120,16 @@ export default function (nextConfig = {}) {
|
|||||||
pageTitle: item.doc.pageTitle,
|
pageTitle: item.doc.pageTitle,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
`;
|
`
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
});
|
})
|
||||||
|
|
||||||
if (typeof nextConfig.webpack === "function") {
|
if (typeof nextConfig.webpack === 'function') {
|
||||||
return nextConfig.webpack(config, options);
|
return nextConfig.webpack(config, options)
|
||||||
}
|
}
|
||||||
|
|
||||||
return config;
|
return config
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,62 +12,60 @@
|
|||||||
},
|
},
|
||||||
"browserslist": "defaults, not ie <= 11",
|
"browserslist": "defaults, not ie <= 11",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@algolia/autocomplete-core": "^1.13.0",
|
"@algolia/autocomplete-core": "^1.17.0",
|
||||||
"@calcom/embed-react": "^1.3.0",
|
"@calcom/embed-react": "^1.3.2",
|
||||||
"@docsearch/react": "^3.5.2",
|
"@docsearch/react": "^3.6.0",
|
||||||
"@formbricks/lib": "workspace:*",
|
"@formbricks/lib": "workspace:*",
|
||||||
"@formbricks/types": "workspace:*",
|
"@formbricks/types": "workspace:*",
|
||||||
"@formbricks/ui": "workspace:*",
|
"@formbricks/ui": "workspace:*",
|
||||||
"@headlessui/react": "^1.7.17",
|
"@headlessui/react": "^1.7.18",
|
||||||
"@headlessui/tailwindcss": "^0.2.0",
|
"@headlessui/tailwindcss": "^0.2.0",
|
||||||
"@heroicons/react": "^2.1.1",
|
"lucide-react": "^0.356.0",
|
||||||
"@mapbox/rehype-prism": "^0.9.0",
|
"@mapbox/rehype-prism": "^0.9.0",
|
||||||
"@mdx-js/loader": "^3.0.0",
|
"@mdx-js/loader": "^3.0.1",
|
||||||
"@mdx-js/react": "^3.0.0",
|
"@mdx-js/react": "^3.0.1",
|
||||||
"@next/mdx": "14.0.4",
|
"@next/mdx": "14.1.3",
|
||||||
"@paralleldrive/cuid2": "^2.2.2",
|
"@paralleldrive/cuid2": "^2.2.2",
|
||||||
"@radix-ui/react-slider": "^1.1.2",
|
|
||||||
"@radix-ui/react-tooltip": "^1.0.6",
|
|
||||||
"@sindresorhus/slugify": "^2.2.1",
|
"@sindresorhus/slugify": "^2.2.1",
|
||||||
"@tailwindcss/typography": "^0.5.10",
|
"@tailwindcss/typography": "^0.5.10",
|
||||||
"@types/dompurify": "^3.0.5",
|
"acorn": "^8.11.3",
|
||||||
"@types/react-highlight-words": "^0.16.5",
|
"autoprefixer": "^10.4.18",
|
||||||
"acorn": "^8.10.0",
|
"clsx": "^2.1.0",
|
||||||
"autoprefixer": "^10.4.15",
|
"fast-glob": "^3.3.2",
|
||||||
"clsx": "^2.0.0",
|
"flexsearch": "^0.7.43",
|
||||||
"fast-glob": "^3.3.1",
|
"framer-motion": "11.0.13",
|
||||||
"flexsearch": "^0.7.31",
|
|
||||||
"framer-motion": "10.17.8",
|
|
||||||
"lottie-web": "^5.12.2",
|
"lottie-web": "^5.12.2",
|
||||||
"mdast-util-to-string": "^4.0.0",
|
"mdast-util-to-string": "^4.0.0",
|
||||||
"mdx-annotations": "^0.1.4",
|
"mdx-annotations": "^0.1.4",
|
||||||
"next": "13.4.19",
|
"next": "14.1.3",
|
||||||
"next-plausible": "^3.12.0",
|
"next-plausible": "^3.12.0",
|
||||||
"next-seo": "^6.4.0",
|
"next-seo": "^6.5.0",
|
||||||
"next-sitemap": "^4.2.3",
|
"next-sitemap": "^4.2.3",
|
||||||
"next-themes": "^0.2.1",
|
"next-themes": "^0.3.0",
|
||||||
"node-fetch": "^3.3.2",
|
"node-fetch": "^3.3.2",
|
||||||
"prism-react-renderer": "^2.3.1",
|
"prism-react-renderer": "^2.3.1",
|
||||||
"prismjs": "^1.29.0",
|
"prismjs": "^1.29.0",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"react-highlight-words": "^0.20.0",
|
"react-highlight-words": "^0.20.0",
|
||||||
"react-icons": "^4.12.0",
|
"react-icons": "^5.0.1",
|
||||||
"react-markdown": "^9.0.1",
|
"react-markdown": "^9.0.1",
|
||||||
"react-responsive-embed": "^2.1.0",
|
"react-responsive-embed": "^2.1.0",
|
||||||
"remark": "^15.0.1",
|
"remark": "^15.0.1",
|
||||||
"remark-gfm": "^4.0.0",
|
"remark-gfm": "^4.0.0",
|
||||||
"remark-mdx": "^3.0.0",
|
"remark-mdx": "^3.0.1",
|
||||||
"sharp": "^0.33.1",
|
"sharp": "^0.33.2",
|
||||||
"shiki": "^0.14.7",
|
"shiki": "^0.14.7",
|
||||||
"simple-functional-loader": "^1.2.1",
|
"simple-functional-loader": "^1.2.1",
|
||||||
"tailwindcss": "^3.4.0",
|
"tailwindcss": "^3.4.1",
|
||||||
"unist-util-filter": "^5.0.1",
|
"unist-util-filter": "^5.0.1",
|
||||||
"unist-util-visit": "^5.0.0",
|
"unist-util-visit": "^5.0.0",
|
||||||
"zustand": "^4.4.7"
|
"zustand": "^4.5.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@formbricks/tsconfig": "workspace:*",
|
"@formbricks/tsconfig": "workspace:*",
|
||||||
|
"@types/dompurify": "^3.0.5",
|
||||||
|
"@types/react-highlight-words": "^0.16.7",
|
||||||
"eslint-config-formbricks": "workspace:*"
|
"eslint-config-formbricks": "workspace:*"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,6 +121,11 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
|
|||||||
"Open source, end-to-end encrypted platform that lets you securely manage secrets and configs across your team, devices, and infrastructure.",
|
"Open source, end-to-end encrypted platform that lets you securely manage secrets and configs across your team, devices, and infrastructure.",
|
||||||
href: "https://infisical.com",
|
href: "https://infisical.com",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "Keep",
|
||||||
|
description: "Open source alert management and AIOps platform.",
|
||||||
|
href: "https://keephq.dev",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "Langfuse",
|
name: "Langfuse",
|
||||||
description: "Open source LLM engineering platform. Debug, analyze and iterate together.",
|
description: "Open source LLM engineering platform. Debug, analyze and iterate together.",
|
||||||
@@ -169,7 +174,7 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
|
|||||||
name: "Requestly",
|
name: "Requestly",
|
||||||
description:
|
description:
|
||||||
"Makes frontend development cycle 10x faster with API Client, Mock Server, Intercept & Modify HTTP Requests and Session Replays.",
|
"Makes frontend development cycle 10x faster with API Client, Mock Server, Intercept & Modify HTTP Requests and Session Replays.",
|
||||||
href: "https://requestly.io",
|
href: "https://requestly.com",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Revert",
|
name: "Revert",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import footerLogoDark from "@/images/logo/footerlogo-dark.svg";
|
import footerLogoDark from "@/images/logo/footerlogo-dark.svg";
|
||||||
import { Bars3Icon } from "@heroicons/react/24/solid";
|
import { MenuIcon } from "lucide-react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
@@ -50,7 +50,7 @@ export default function HeaderLight() {
|
|||||||
<Popover open={mobileNavMenuOpen} onOpenChange={setMobileNavMenuOpen}>
|
<Popover open={mobileNavMenuOpen} onOpenChange={setMobileNavMenuOpen}>
|
||||||
<PopoverTrigger onClick={() => setMobileNavMenuOpen(!mobileNavMenuOpen)}>
|
<PopoverTrigger onClick={() => setMobileNavMenuOpen(!mobileNavMenuOpen)}>
|
||||||
<span>
|
<span>
|
||||||
<Bars3Icon className="h-8 w-8 rounded-md bg-slate-700 p-1 text-slate-200" />
|
<MenuIcon className="h-8 w-8 rounded-md bg-slate-700 p-1 text-slate-200" />
|
||||||
</span>
|
</span>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="border-slate-600 bg-slate-700 shadow">
|
<PopoverContent className="border-slate-600 bg-slate-700 shadow">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ChevronDownIcon } from "@heroicons/react/24/outline";
|
import { ChevronDownIcon } from "lucide-react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { FaGithub } from "react-icons/fa6";
|
import { FaGithub } from "react-icons/fa6";
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
{
|
{
|
||||||
"extends": "@formbricks/tsconfig/nextjs.json",
|
"extends": "@formbricks/tsconfig/nextjs.json",
|
||||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "../../packages/types/*.d.ts"],
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "../../packages/types/*.d.ts"],
|
||||||
|
"exclude": ["../../.env"],
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./*"]
|
"@/*": ["./*"]
|
||||||
},
|
},
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
"strictNullChecks": true
|
"strictNullChecks": true
|
||||||
},
|
}
|
||||||
"plugins": [
|
|
||||||
{
|
|
||||||
"name": "next"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-12
@@ -17,19 +17,19 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@formbricks/tsconfig": "workspace:*",
|
"@formbricks/tsconfig": "workspace:*",
|
||||||
"@storybook/addon-essentials": "^7.6.7",
|
"@storybook/addon-essentials": "^8.0.0",
|
||||||
"@storybook/addon-interactions": "^7.6.7",
|
"@storybook/addon-interactions": "^8.0.0",
|
||||||
"@storybook/addon-links": "^7.6.7",
|
"@storybook/addon-links": "^8.0.0",
|
||||||
"@storybook/addon-onboarding": "^1.0.10",
|
"@storybook/addon-onboarding": "^8.0.0",
|
||||||
"@storybook/blocks": "^7.6.7",
|
"@storybook/blocks": "^8.0.0",
|
||||||
"@storybook/react": "^7.6.7",
|
"@storybook/react": "^8.0.0",
|
||||||
"@storybook/react-vite": "^7.6.7",
|
"@storybook/react-vite": "^8.0.0",
|
||||||
"@storybook/testing-library": "^0.2.2",
|
"@storybook/testing-library": "^0.2.2",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.18.1",
|
"@typescript-eslint/eslint-plugin": "^7.2.0",
|
||||||
"@typescript-eslint/parser": "^6.18.1",
|
"@typescript-eslint/parser": "^7.2.0",
|
||||||
"@vitejs/plugin-react": "^4.2.1",
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
"esbuild": "^0.19.11",
|
"esbuild": "^0.20.1",
|
||||||
"tsup": "^8.0.1",
|
"tsup": "^8.0.2",
|
||||||
"vite": "^5.0.12"
|
"vite": "^5.1.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -42,4 +42,5 @@ next-env.d.ts
|
|||||||
token.json
|
token.json
|
||||||
|
|
||||||
# Local Uploads
|
# Local Uploads
|
||||||
uploads/
|
uploads/
|
||||||
|
certificates
|
||||||
+12
-24
@@ -23,21 +23,11 @@ RUN corepack enable && corepack prepare pnpm@latest --activate
|
|||||||
# Install necessary build tools and compilers
|
# Install necessary build tools and compilers
|
||||||
RUN apk update && apk add --no-cache g++ cmake make gcc python3 openssl-dev jq
|
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 environment variables
|
# Set hardcoded environment variables
|
||||||
ARG DATABASE_URL
|
ENV DATABASE_URL="postgresql://placeholder:for@build:5432/gets_overwritten_at_runtime?schema=public"
|
||||||
ENV DATABASE_URL=$DATABASE_URL
|
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"
|
||||||
ARG NEXTAUTH_SECRET
|
|
||||||
ENV NEXTAUTH_SECRET=$NEXTAUTH_SECRET
|
|
||||||
|
|
||||||
ARG ENCRYPTION_KEY
|
|
||||||
ENV ENCRYPTION_KEY=$ENCRYPTION_KEY
|
|
||||||
|
|
||||||
ARG NEXT_PUBLIC_SENTRY_DSN
|
ARG NEXT_PUBLIC_SENTRY_DSN
|
||||||
|
|
||||||
@@ -71,12 +61,12 @@ FROM base AS runner
|
|||||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||||
|
|
||||||
RUN apk add --no-cache curl \
|
RUN apk add --no-cache curl \
|
||||||
|
&& apk add --no-cache supercronic \
|
||||||
# && addgroup --system --gid 1001 nodejs \
|
# && addgroup --system --gid 1001 nodejs \
|
||||||
&& adduser --system --uid 1001 nextjs
|
&& adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
WORKDIR /home/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/next.config.mjs .
|
||||||
COPY --from=installer /app/apps/web/package.json .
|
COPY --from=installer /app/apps/web/package.json .
|
||||||
# Leverage output traces to reduce image size
|
# Leverage output traces to reduce image size
|
||||||
@@ -84,10 +74,14 @@ 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/.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/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/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 /app/packages/database/migrations ./packages/database/migrations
|
||||||
COPY --from=installer --chown=nextjs:nextjs /prisma_version.txt .
|
COPY --from=installer --chown=nextjs:nextjs /prisma_version.txt .
|
||||||
COPY /docker/cronjobs /app/docker/cronjobs
|
COPY /docker/cronjobs /app/docker/cronjobs
|
||||||
|
|
||||||
|
# Install Prisma globally
|
||||||
|
RUN PRISMA_VERSION=$(cat prisma_version.txt) && npm install -g prisma@$PRISMA_VERSION
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
ENV HOSTNAME "0.0.0.0"
|
ENV HOSTNAME "0.0.0.0"
|
||||||
USER nextjs
|
USER nextjs
|
||||||
@@ -96,12 +90,6 @@ USER nextjs
|
|||||||
RUN mkdir -p /home/nextjs/apps/web/uploads/
|
RUN mkdir -p /home/nextjs/apps/web/uploads/
|
||||||
VOLUME /home/nextjs/apps/web/uploads/
|
VOLUME /home/nextjs/apps/web/uploads/
|
||||||
|
|
||||||
CMD PRISMA_VERSION=$(cat prisma_version.txt) && \
|
CMD supercronic -quiet /app/docker/cronjobs & \
|
||||||
supercronic -quiet /app/docker/cronjobs & \
|
(cd packages/database && pnpm db:migrate:deploy) && \
|
||||||
if [ "$NEXTAUTH_SECRET" != "RANDOM_STRING" ]; then \
|
exec node apps/web/server.js
|
||||||
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
|
|
||||||
|
|||||||
+4
-4
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { CodeBracketIcon, CursorArrowRaysIcon, SparklesIcon } from "@heroicons/react/24/solid";
|
import { Code2Icon, MousePointerClickIcon, SparklesIcon } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
import { capitalizeFirstLetter } from "@formbricks/lib/strings";
|
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="mt-1 flex items-center">
|
||||||
<div className="mr-1.5 h-4 w-4 text-slate-600">
|
<div className="mr-1.5 h-4 w-4 text-slate-600">
|
||||||
{actionClass.type === "code" ? (
|
{actionClass.type === "code" ? (
|
||||||
<CodeBracketIcon />
|
<Code2Icon className="h-5 w-5" />
|
||||||
) : actionClass.type === "noCode" ? (
|
) : actionClass.type === "noCode" ? (
|
||||||
<CursorArrowRaysIcon />
|
<MousePointerClickIcon className="h-5 w-5" />
|
||||||
) : actionClass.type === "automatic" ? (
|
) : actionClass.type === "automatic" ? (
|
||||||
<SparklesIcon />
|
<SparklesIcon className="h-5 w-5" />
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-slate-700 ">{capitalizeFirstLetter(actionClass.type)}</p>
|
<p className="text-sm text-slate-700 ">{capitalizeFirstLetter(actionClass.type)}</p>
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { CursorArrowRaysIcon } from "@heroicons/react/24/solid";
|
import { MousePointerClickIcon } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import { useMembershipRole } from "@formbricks/lib/membership/hooks/useMembershipRole";
|
import { useMembershipRole } from "@formbricks/lib/membership/hooks/useMembershipRole";
|
||||||
@@ -53,7 +53,7 @@ export default function ActionClassesTable({
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setAddActionModalOpen(true);
|
setAddActionModalOpen(true);
|
||||||
}}>
|
}}>
|
||||||
<CursorArrowRaysIcon className="mr-2 h-5 w-5 text-white" />
|
<MousePointerClickIcon className="mr-2 h-5 w-5 text-white" />
|
||||||
Add Action
|
Add Action
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+4
-4
@@ -1,4 +1,4 @@
|
|||||||
import { CodeBracketIcon, CursorArrowRaysIcon, SparklesIcon } from "@heroicons/react/24/solid";
|
import { Code2Icon, MousePointerClickIcon, SparklesIcon } from "lucide-react";
|
||||||
|
|
||||||
import { TActionClass } from "@formbricks/types/actionClasses";
|
import { TActionClass } from "@formbricks/types/actionClasses";
|
||||||
import { TMembershipRole } from "@formbricks/types/memberships";
|
import { TMembershipRole } from "@formbricks/types/memberships";
|
||||||
@@ -48,11 +48,11 @@ export default function ActionDetailModal({
|
|||||||
tabs={tabs}
|
tabs={tabs}
|
||||||
icon={
|
icon={
|
||||||
actionClass.type === "code" ? (
|
actionClass.type === "code" ? (
|
||||||
<CodeBracketIcon />
|
<Code2Icon className="h-5 w-5" />
|
||||||
) : actionClass.type === "noCode" ? (
|
) : actionClass.type === "noCode" ? (
|
||||||
<CursorArrowRaysIcon />
|
<MousePointerClickIcon className="h-5 w-5" />
|
||||||
) : actionClass.type === "automatic" ? (
|
) : actionClass.type === "automatic" ? (
|
||||||
<SparklesIcon />
|
<SparklesIcon className="h-5 w-5" />
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
label={actionClass.name}
|
label={actionClass.name}
|
||||||
|
|||||||
+4
-4
@@ -1,4 +1,4 @@
|
|||||||
import { CodeBracketIcon, CursorArrowRaysIcon, SparklesIcon } from "@heroicons/react/24/solid";
|
import { Code2Icon, MousePointerClickIcon, SparklesIcon } from "lucide-react";
|
||||||
|
|
||||||
import { timeSinceConditionally } from "@formbricks/lib/time";
|
import { timeSinceConditionally } from "@formbricks/lib/time";
|
||||||
import { TActionClass } from "@formbricks/types/actionClasses";
|
import { TActionClass } from "@formbricks/types/actionClasses";
|
||||||
@@ -10,11 +10,11 @@ export default function ActionClassDataRow({ actionClass }: { actionClass: TActi
|
|||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div className="h-5 w-5 flex-shrink-0 text-slate-500">
|
<div className="h-5 w-5 flex-shrink-0 text-slate-500">
|
||||||
{actionClass.type === "code" ? (
|
{actionClass.type === "code" ? (
|
||||||
<CodeBracketIcon />
|
<Code2Icon className="h-5 w-5" />
|
||||||
) : actionClass.type === "noCode" ? (
|
) : actionClass.type === "noCode" ? (
|
||||||
<CursorArrowRaysIcon />
|
<MousePointerClickIcon className="h-5 w-5" />
|
||||||
) : actionClass.type === "automatic" ? (
|
) : actionClass.type === "automatic" ? (
|
||||||
<SparklesIcon />
|
<SparklesIcon className="h-5 w-5" />
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-4 text-left">
|
<div className="ml-4 text-left">
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ import {
|
|||||||
deleteActionClassAction,
|
deleteActionClassAction,
|
||||||
updateActionClassAction,
|
updateActionClassAction,
|
||||||
} from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/actions";
|
} from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/actions";
|
||||||
import { TrashIcon } from "@heroicons/react/24/outline";
|
import { TrashIcon } from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
|
|||||||
+4
-4
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { createActionClassAction } from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/actions";
|
import { createActionClassAction } from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/actions/actions";
|
||||||
import { CursorArrowRaysIcon } from "@heroicons/react/24/solid";
|
import { MousePointerClickIcon } from "lucide-react";
|
||||||
import { Terminal } from "lucide-react";
|
import { Terminal } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
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 w-full items-center justify-between p-6">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<div className="mr-1.5 h-6 w-6 text-slate-500">
|
<div className="mr-1.5 h-6 w-6 text-slate-500">
|
||||||
<CursorArrowRaysIcon />
|
<MousePointerClickIcon className="h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xl font-medium text-slate-700">Track New User Action</div>
|
<div className="text-xl font-medium text-slate-700">Track New User Action</div>
|
||||||
@@ -224,7 +224,7 @@ export default function AddNoCodeActionModal({
|
|||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="darkCTA" type="submit" loading={isCreatingAction}>
|
<Button variant="darkCTA" type="submit" loading={isCreatingAction}>
|
||||||
Track Action
|
Create Action
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -275,7 +275,7 @@ export default function AddNoCodeActionModal({
|
|||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="darkCTA" type="submit" loading={isCreatingAction}>
|
<Button variant="darkCTA" type="submit" loading={isCreatingAction}>
|
||||||
Track Action
|
Create Action
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
import { CursorArrowRaysIcon } from "@heroicons/react/24/solid";
|
import { MousePointerClickIcon } from "lucide-react";
|
||||||
|
|
||||||
import { Button } from "@formbricks/ui/Button";
|
import { Button } from "@formbricks/ui/Button";
|
||||||
|
|
||||||
@@ -9,7 +9,7 @@ export default function Loading() {
|
|||||||
<Button
|
<Button
|
||||||
variant="darkCTA"
|
variant="darkCTA"
|
||||||
className="pointer-events-none animate-pulse cursor-not-allowed select-none bg-slate-200">
|
className="pointer-events-none animate-pulse cursor-not-allowed select-none bg-slate-200">
|
||||||
<CursorArrowRaysIcon className="mr-2 h-5 w-5 text-white" />
|
<MousePointerClickIcon className="mr-2 h-5 w-5 text-white" />
|
||||||
Loading
|
Loading
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+3
-3
@@ -1,5 +1,5 @@
|
|||||||
import SecondNavbar from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/attributes/components/SecondNavbar";
|
import SecondNavbar from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/attributes/components/SecondNavbar";
|
||||||
import { CursorArrowRaysIcon, TagIcon } from "@heroicons/react/24/solid";
|
import { MousePointerClickIcon, TagIcon } from "lucide-react";
|
||||||
|
|
||||||
interface ActionsAttributesTabsProps {
|
interface ActionsAttributesTabsProps {
|
||||||
activeId: string;
|
activeId: string;
|
||||||
@@ -11,13 +11,13 @@ export default function ActionsAttributesTabs({ activeId, environmentId }: Actio
|
|||||||
{
|
{
|
||||||
id: "actions",
|
id: "actions",
|
||||||
label: "Actions",
|
label: "Actions",
|
||||||
icon: <CursorArrowRaysIcon />,
|
icon: <MousePointerClickIcon className="h-5 w-5" />,
|
||||||
href: `/environments/${environmentId}/actions`,
|
href: `/environments/${environmentId}/actions`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "attributes",
|
id: "attributes",
|
||||||
label: "Attributes",
|
label: "Attributes",
|
||||||
icon: <TagIcon />,
|
icon: <TagIcon className="h-5 w-5" />,
|
||||||
href: `/environments/${environmentId}/attributes`,
|
href: `/environments/${environmentId}/attributes`,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { getSegmentsByAttributeClassAction } from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/attributes/actions";
|
import { getSegmentsByAttributeClassAction } from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/attributes/actions";
|
||||||
import { TagIcon } from "@heroicons/react/24/solid";
|
import { TagIcon } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
import { capitalizeFirstLetter } from "@formbricks/lib/strings";
|
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>
|
<Label className="block text-xs font-normal text-slate-500">Type</Label>
|
||||||
<div className="mt-1 flex items-center">
|
<div className="mt-1 flex items-center">
|
||||||
<div className="mr-1.5 h-4 w-4 text-slate-600">
|
<div className="mr-1.5 h-4 w-4 text-slate-600">
|
||||||
<TagIcon />
|
<TagIcon className="h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-slate-700 ">{capitalizeFirstLetter(attributeClass.type)}</p>
|
<p className="text-sm text-slate-700 ">{capitalizeFirstLetter(attributeClass.type)}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
import { TagIcon } from "@heroicons/react/24/solid";
|
import { TagIcon } from "lucide-react";
|
||||||
|
|
||||||
import { TAttributeClass } from "@formbricks/types/attributeClasses";
|
import { TAttributeClass } from "@formbricks/types/attributeClasses";
|
||||||
import ModalWithTabs from "@formbricks/ui/ModalWithTabs";
|
import ModalWithTabs from "@formbricks/ui/ModalWithTabs";
|
||||||
@@ -30,7 +30,7 @@ export default function AttributeDetailModal({ open, setOpen, attributeClass }:
|
|||||||
open={open}
|
open={open}
|
||||||
setOpen={setOpen}
|
setOpen={setOpen}
|
||||||
tabs={tabs}
|
tabs={tabs}
|
||||||
icon={<TagIcon />}
|
icon={<TagIcon className="h-5 w-5" />}
|
||||||
label={attributeClass.name}
|
label={attributeClass.name}
|
||||||
description={attributeClass.description || ""}
|
description={attributeClass.description || ""}
|
||||||
/>
|
/>
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import { TagIcon } from "@heroicons/react/24/solid";
|
import { TagIcon } from "lucide-react";
|
||||||
|
|
||||||
import { timeSinceConditionally } from "@formbricks/lib/time";
|
import { timeSinceConditionally } from "@formbricks/lib/time";
|
||||||
import { Badge } from "@formbricks/ui/Badge";
|
import { Badge } from "@formbricks/ui/Badge";
|
||||||
|
|||||||
+3
-3
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ArchiveBoxArrowDownIcon, ArchiveBoxXMarkIcon } from "@heroicons/react/24/solid";
|
|
||||||
import type { AttributeClass } from "@prisma/client";
|
import type { AttributeClass } from "@prisma/client";
|
||||||
|
import { ArchiveIcon, ArchiveXIcon } from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
@@ -86,13 +86,13 @@ export default function AttributeSettingsTab({ attributeClass, setOpen }: Attrib
|
|||||||
{attributeClass.archived ? (
|
{attributeClass.archived ? (
|
||||||
<>
|
<>
|
||||||
{" "}
|
{" "}
|
||||||
<ArchiveBoxXMarkIcon className="mr-2 h-4 text-slate-600" />
|
<ArchiveXIcon className="mr-2 h-4 text-slate-600" />
|
||||||
<span>Unarchive</span>
|
<span>Unarchive</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{" "}
|
{" "}
|
||||||
<ArchiveBoxArrowDownIcon className="mr-2 h-4 text-slate-600" />
|
<ArchiveIcon className="mr-2 h-4 text-slate-600" />
|
||||||
<span>Archive</span>
|
<span>Archive</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
import { QuestionMarkCircleIcon } from "@heroicons/react/24/solid";
|
import { HelpCircleIcon } from "lucide-react";
|
||||||
|
|
||||||
import { Button } from "@formbricks/ui/Button";
|
import { Button } from "@formbricks/ui/Button";
|
||||||
|
|
||||||
@@ -8,7 +8,7 @@ export default function HowToAddAttributesButton() {
|
|||||||
variant="secondary"
|
variant="secondary"
|
||||||
href="https://formbricks.com/docs/attributes/custom-attributes"
|
href="https://formbricks.com/docs/attributes/custom-attributes"
|
||||||
target="_blank">
|
target="_blank">
|
||||||
<QuestionMarkCircleIcon className="mr-2 h-4 w-4" />
|
<HelpCircleIcon className="mr-2 h-4 w-4" />
|
||||||
How to add attributes
|
How to add attributes
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
|
|||||||
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
import { QuestionMarkCircleIcon, TagIcon } from "@heroicons/react/24/solid";
|
import { HelpCircleIcon, TagIcon } from "lucide-react";
|
||||||
|
|
||||||
import { Button } from "@formbricks/ui/Button";
|
import { Button } from "@formbricks/ui/Button";
|
||||||
|
|
||||||
@@ -10,7 +10,7 @@ export default function Loading() {
|
|||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="pointer-events-none animate-pulse cursor-not-allowed select-none">
|
className="pointer-events-none animate-pulse cursor-not-allowed select-none">
|
||||||
<QuestionMarkCircleIcon className="mr-2 h-4 w-4" />
|
<HelpCircleIcon className="mr-2 h-4 w-4" />
|
||||||
Loading Attributes
|
Loading Attributes
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+3
-3
@@ -1,5 +1,5 @@
|
|||||||
import SecondNavbar from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/attributes/components/SecondNavbar";
|
import SecondNavbar from "@/app/(app)/environments/[environmentId]/(actionsAndAttributes)/attributes/components/SecondNavbar";
|
||||||
import { UserGroupIcon, UserIcon } from "@heroicons/react/24/solid";
|
import { UserIcon, UsersIcon } from "lucide-react";
|
||||||
|
|
||||||
interface PeopleSegmentsTabsProps {
|
interface PeopleSegmentsTabsProps {
|
||||||
activeId: string;
|
activeId: string;
|
||||||
@@ -12,13 +12,13 @@ export default function PeopleSegmentsTabs({ activeId, environmentId }: PeopleSe
|
|||||||
{
|
{
|
||||||
id: "people",
|
id: "people",
|
||||||
label: "People",
|
label: "People",
|
||||||
icon: <UserIcon />,
|
icon: <UserIcon className="h-5 w-5" />,
|
||||||
href: `/environments/${environmentId}/people`,
|
href: `/environments/${environmentId}/people`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "segments",
|
id: "segments",
|
||||||
label: "Segments",
|
label: "Segments",
|
||||||
icon: <UserGroupIcon />,
|
icon: <UsersIcon className="h-5 w-5" />,
|
||||||
href: `/environments/${environmentId}/segments`,
|
href: `/environments/${environmentId}/segments`,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
+4
-4
@@ -1,5 +1,5 @@
|
|||||||
import { CodeBracketIcon, CursorArrowRaysIcon, SparklesIcon } from "@heroicons/react/24/solid";
|
|
||||||
import { formatDistance } from "date-fns";
|
import { formatDistance } from "date-fns";
|
||||||
|
import { CodeIcon, MousePointerClickIcon, SparklesIcon } from "lucide-react";
|
||||||
|
|
||||||
import { TAction } from "@formbricks/types/actions";
|
import { TAction } from "@formbricks/types/actions";
|
||||||
import { Label } from "@formbricks/ui/Label";
|
import { Label } from "@formbricks/ui/Label";
|
||||||
@@ -8,9 +8,9 @@ import { Popover, PopoverContent, PopoverTrigger } from "@formbricks/ui/Popover"
|
|||||||
export const ActivityItemIcon = ({ actionItem }: { actionItem: TAction }) => (
|
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 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>
|
<div>
|
||||||
{actionItem.actionClass?.type === "code" && <CodeBracketIcon />}
|
{actionItem.actionClass?.type === "code" && <CodeIcon className="h-5 w-5" />}
|
||||||
{actionItem.actionClass?.type === "noCode" && <CursorArrowRaysIcon />}
|
{actionItem.actionClass?.type === "noCode" && <MousePointerClickIcon className="h-5 w-5" />}
|
||||||
{actionItem.actionClass?.type === "automatic" && <SparklesIcon />}
|
{actionItem.actionClass?.type === "automatic" && <SparklesIcon className="h-5 w-5" />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import ResponseFeed from "@/app/(app)/environments/[environmentId]/(peopleAndSegments)/people/[personId]/components/ResponsesFeed";
|
import ResponseFeed from "@/app/(app)/environments/[environmentId]/(peopleAndSegments)/people/[personId]/components/ResponsesFeed";
|
||||||
import { ArrowsUpDownIcon } from "@heroicons/react/24/outline";
|
import { ArrowDownUpIcon } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
import { TEnvironment } from "@formbricks/types/environment";
|
import { TEnvironment } from "@formbricks/types/environment";
|
||||||
@@ -41,7 +41,7 @@ export default function ResponseTimeline({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={toggleSortResponses}
|
onClick={toggleSortResponses}
|
||||||
className="hover:text-brand-dark flex items-center px-1 text-slate-800">
|
className="hover:text-brand-dark flex items-center px-1 text-slate-800">
|
||||||
<ArrowsUpDownIcon className="inline h-4 w-4" />
|
<ArrowDownUpIcon className="inline h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+57
-21
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react";
|
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 { TEnvironment } from "@formbricks/types/environment";
|
||||||
import { TResponse } from "@formbricks/types/responses";
|
import { TResponse } from "@formbricks/types/responses";
|
||||||
import { TSurvey } from "@formbricks/types/surveys";
|
import { TSurvey } from "@formbricks/types/surveys";
|
||||||
@@ -44,28 +46,62 @@ export default function ResponseFeed({
|
|||||||
{fetchedResponses.length === 0 ? (
|
{fetchedResponses.length === 0 ? (
|
||||||
<EmptySpaceFiller type="response" environment={environment} />
|
<EmptySpaceFiller type="response" environment={environment} />
|
||||||
) : (
|
) : (
|
||||||
fetchedResponses.map((response) => {
|
fetchedResponses.map((response) => (
|
||||||
const survey = surveys.find((survey) => {
|
<ResponseSurveyCard
|
||||||
return survey.id === response.surveyId;
|
key={response.id}
|
||||||
});
|
response={response}
|
||||||
return (
|
surveys={surveys}
|
||||||
<div key={response.id}>
|
user={user}
|
||||||
{survey && (
|
environmentTags={environmentTags}
|
||||||
<SingleResponseCard
|
environment={environment}
|
||||||
response={response}
|
deleteResponse={deleteResponse}
|
||||||
survey={survey}
|
updateResponse={updateResponse}
|
||||||
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
+2
-2
@@ -2,7 +2,7 @@ import {
|
|||||||
ActivityItemIcon,
|
ActivityItemIcon,
|
||||||
ActivityItemPopover,
|
ActivityItemPopover,
|
||||||
} from "@/app/(app)/environments/[environmentId]/(peopleAndSegments)/people/[personId]/components/ActivityItemComponents";
|
} from "@/app/(app)/environments/[environmentId]/(peopleAndSegments)/people/[personId]/components/ActivityItemComponents";
|
||||||
import { ArrowsUpDownIcon } from "@heroicons/react/24/outline";
|
import { ArrowDownUpIcon } from "lucide-react";
|
||||||
import { TrashIcon } from "lucide-react";
|
import { TrashIcon } from "lucide-react";
|
||||||
|
|
||||||
import { TAction } from "@formbricks/types/actions";
|
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>
|
<h2 className="text-lg font-bold text-slate-700">Responses</h2>
|
||||||
<div className="text-right">
|
<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">
|
<button className="hover:text-brand-dark pointer-events-none flex animate-pulse cursor-not-allowed select-none items-center px-1 text-slate-800">
|
||||||
<ArrowsUpDownIcon className="inline h-4 w-4" />
|
<ArrowDownUpIcon className="inline h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { UserGroupIcon } from "@heroicons/react/20/solid";
|
import { UsersIcon } from "lucide-react";
|
||||||
import { FilterIcon } from "lucide-react";
|
import { FilterIcon } from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useMemo, useState } from "react";
|
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 w-full items-center gap-4 p-6">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<div className="mr-1.5 h-6 w-6 text-slate-500">
|
<div className="mr-1.5 h-6 w-6 text-slate-500">
|
||||||
<UserGroupIcon />
|
<UsersIcon className="h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-base font-medium">Create Segment</h3>
|
<h3 className="text-base font-medium">Create Segment</h3>
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { UserGroupIcon } from "@heroicons/react/24/solid";
|
import { UsersIcon } from "lucide-react";
|
||||||
|
|
||||||
import SegmentSettings from "@formbricks/ee/advancedTargeting/components/SegmentSettings";
|
import SegmentSettings from "@formbricks/ee/advancedTargeting/components/SegmentSettings";
|
||||||
import { TActionClass } from "@formbricks/types/actionClasses";
|
import { TActionClass } from "@formbricks/types/actionClasses";
|
||||||
@@ -76,7 +76,7 @@ export default function EditSegmentModal({
|
|||||||
open={open}
|
open={open}
|
||||||
setOpen={setOpen}
|
setOpen={setOpen}
|
||||||
tabs={tabs}
|
tabs={tabs}
|
||||||
icon={<UserGroupIcon />}
|
icon={<UsersIcon className="h-5 w-5" />}
|
||||||
label={currentSegment.title}
|
label={currentSegment.title}
|
||||||
description={currentSegment.description || ""}
|
description={currentSegment.description || ""}
|
||||||
closeOnOutsideClick={false}
|
closeOnOutsideClick={false}
|
||||||
|
|||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { UserGroupIcon } from "@heroicons/react/24/solid";
|
|
||||||
import { format, formatDistanceToNow } from "date-fns";
|
import { format, formatDistanceToNow } from "date-fns";
|
||||||
|
import { UsersIcon } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import { TActionClass } from "@formbricks/types/actionClasses";
|
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="col-span-4 flex items-center pl-6 text-sm">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="ph-no-capture h-8 w-8 flex-shrink-0 text-slate-700">
|
<div className="ph-no-capture h-8 w-8 flex-shrink-0 text-slate-700">
|
||||||
<UserGroupIcon />
|
<UsersIcon className="h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<div className="ph-no-capture font-medium text-slate-900">{title}</div>
|
<div className="ph-no-capture font-medium text-slate-900">{title}</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { createProductAction } from "@/app/(app)/environments/[environmentId]/actions";
|
import { createProductAction } from "@/app/(app)/environments/[environmentId]/actions";
|
||||||
import { PlusCircleIcon } from "@heroicons/react/24/outline";
|
import { PlusCircleIcon } from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
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 justify-between p-6">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<div className="mr-1.5 h-10 w-10 text-slate-500">
|
<div className="mr-1.5 h-10 w-10 text-slate-500">
|
||||||
<PlusCircleIcon />
|
<PlusCircleIcon className="h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xl font-medium text-slate-700">Add Product</div>
|
<div className="text-xl font-medium text-slate-700">Add Product</div>
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { QuestionMarkCircleIcon } from "@heroicons/react/24/solid";
|
import { HelpCircleIcon } from "lucide-react";
|
||||||
|
|
||||||
import { Button } from "@formbricks/ui/Button";
|
import { Button } from "@formbricks/ui/Button";
|
||||||
|
|
||||||
export default function HowToAddPeopleButton() {
|
export default function HowToAddPeopleButton() {
|
||||||
return (
|
return (
|
||||||
<Button variant="secondary" href="https://formbricks.com/docs/attributes/identify-users" target="_blank">
|
<Button variant="secondary" href="https://formbricks.com/docs/attributes/identify-users" target="_blank">
|
||||||
<QuestionMarkCircleIcon className="mr-2 h-4 w-4" />
|
<HelpCircleIcon className="mr-2 h-4 w-4" />
|
||||||
How to add people
|
How to add people
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,23 +2,23 @@
|
|||||||
|
|
||||||
import FaveIcon from "@/app/favicon.ico";
|
import FaveIcon from "@/app/favicon.ico";
|
||||||
import { formbricksLogout } from "@/app/lib/formbricks";
|
import { formbricksLogout } from "@/app/lib/formbricks";
|
||||||
|
import clsx from "clsx";
|
||||||
import {
|
import {
|
||||||
AdjustmentsVerticalIcon,
|
BrushIcon,
|
||||||
ArrowRightOnRectangleIcon,
|
|
||||||
ChatBubbleBottomCenterTextIcon,
|
|
||||||
ChevronDownIcon,
|
ChevronDownIcon,
|
||||||
CodeBracketIcon,
|
CodeIcon,
|
||||||
CreditCardIcon,
|
CreditCardIcon,
|
||||||
DocumentCheckIcon,
|
FileCheckIcon,
|
||||||
EnvelopeIcon,
|
|
||||||
HeartIcon,
|
HeartIcon,
|
||||||
LinkIcon,
|
LinkIcon,
|
||||||
PaintBrushIcon,
|
LogOutIcon,
|
||||||
|
MailIcon,
|
||||||
|
MessageSquareTextIcon,
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
|
SlidersIcon,
|
||||||
UserCircleIcon,
|
UserCircleIcon,
|
||||||
UsersIcon,
|
UsersIcon,
|
||||||
} from "@heroicons/react/24/solid";
|
} from "lucide-react";
|
||||||
import clsx from "clsx";
|
|
||||||
import { MenuIcon } from "lucide-react";
|
import { MenuIcon } from "lucide-react";
|
||||||
import type { Session } from "next-auth";
|
import type { Session } from "next-auth";
|
||||||
import { signOut } from "next-auth/react";
|
import { signOut } from "next-auth/react";
|
||||||
@@ -155,13 +155,13 @@ export default function Navigation({
|
|||||||
title: "Survey",
|
title: "Survey",
|
||||||
links: [
|
links: [
|
||||||
{
|
{
|
||||||
icon: AdjustmentsVerticalIcon,
|
icon: SlidersIcon,
|
||||||
label: "Product Settings",
|
label: "Product Settings",
|
||||||
href: `/environments/${environment.id}/settings/product`,
|
href: `/environments/${environment.id}/settings/product`,
|
||||||
hidden: false,
|
hidden: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: PaintBrushIcon,
|
icon: BrushIcon,
|
||||||
label: "Look & Feel",
|
label: "Look & Feel",
|
||||||
href: `/environments/${environment.id}/settings/lookandfeel`,
|
href: `/environments/${environment.id}/settings/lookandfeel`,
|
||||||
hidden: isViewer,
|
hidden: isViewer,
|
||||||
@@ -189,7 +189,7 @@ export default function Navigation({
|
|||||||
title: "Setup",
|
title: "Setup",
|
||||||
links: [
|
links: [
|
||||||
{
|
{
|
||||||
icon: DocumentCheckIcon,
|
icon: FileCheckIcon,
|
||||||
label: "Setup checklist",
|
label: "Setup checklist",
|
||||||
href: `/environments/${environment.id}/settings/setup`,
|
href: `/environments/${environment.id}/settings/setup`,
|
||||||
hidden: widgetSetupCompleted,
|
hidden: widgetSetupCompleted,
|
||||||
@@ -203,7 +203,7 @@ export default function Navigation({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: CodeBracketIcon,
|
icon: CodeIcon,
|
||||||
label: "Developer Docs",
|
label: "Developer Docs",
|
||||||
href: "https://formbricks.com/docs",
|
href: "https://formbricks.com/docs",
|
||||||
target: "_blank",
|
target: "_blank",
|
||||||
@@ -483,7 +483,7 @@ export default function Navigation({
|
|||||||
<DropdownMenuItem>
|
<DropdownMenuItem>
|
||||||
<a href="mailto:johannes@formbricks.com">
|
<a href="mailto:johannes@formbricks.com">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<EnvelopeIcon className="mr-2 h-4 w-4" />
|
<MailIcon className="mr-2 h-4 w-4" />
|
||||||
<span>Email us!</span>
|
<span>Email us!</span>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
@@ -494,7 +494,7 @@ export default function Navigation({
|
|||||||
formbricks.track("Top Menu: Product Feedback");
|
formbricks.track("Top Menu: Product Feedback");
|
||||||
}}>
|
}}>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<ChatBubbleBottomCenterTextIcon className="mr-2 h-4 w-4" />
|
<MessageSquareTextIcon className="mr-2 h-4 w-4" />
|
||||||
<span>Product Feedback</span>
|
<span>Product Feedback</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -507,7 +507,7 @@ export default function Navigation({
|
|||||||
await formbricksLogout();
|
await formbricksLogout();
|
||||||
}}>
|
}}>
|
||||||
<div className="flex h-full w-full items-center">
|
<div className="flex h-full w-full items-center">
|
||||||
<ArrowRightOnRectangleIcon className="mr-2 h-4 w-4" />
|
<LogOutIcon className="mr-2 h-4 w-4" />
|
||||||
Logout
|
Logout
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { LinkIcon } from "@heroicons/react/24/outline";
|
import { LinkIcon } from "lucide-react";
|
||||||
|
|
||||||
import { Modal } from "@formbricks/ui/Modal";
|
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 justify-between p-6">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<div className="mr-1.5 h-10 w-10 text-slate-500">
|
<div className="mr-1.5 h-10 w-10 text-slate-500">
|
||||||
<LinkIcon />
|
<LinkIcon className="h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xl font-medium text-slate-700">URL shortener</div>
|
<div className="text-xl font-medium text-slate-700">URL shortener</div>
|
||||||
|
|||||||
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
import { CheckIcon, ExclamationTriangleIcon } from "@heroicons/react/24/solid";
|
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
import { AlertTriangleIcon, CheckIcon } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
import { getEnvironment } from "@formbricks/lib/environment/service";
|
import { getEnvironment } from "@formbricks/lib/environment/service";
|
||||||
@@ -18,7 +18,7 @@ export default async function WidgetStatusIndicator({ environmentId, type }: Wid
|
|||||||
|
|
||||||
const stati = {
|
const stati = {
|
||||||
notImplemented: {
|
notImplemented: {
|
||||||
icon: ExclamationTriangleIcon,
|
icon: AlertTriangleIcon,
|
||||||
title: "Connect Formbricks to your app or website.",
|
title: "Connect Formbricks to your app or website.",
|
||||||
subtitle:
|
subtitle:
|
||||||
"Your app or website is not yet connected with Formbricks. To run in-app surveys follow the setup guide.",
|
"Your app or website is not yet connected with Formbricks. To run in-app surveys follow the setup guide.",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions";
|
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 * as DropdownMenu from "@radix-ui/react-dropdown-menu";
|
||||||
|
import { ChevronDownIcon } from "lucide-react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
|
|||||||
+3
-3
@@ -6,8 +6,8 @@ import {
|
|||||||
} from "@/app/(app)/environments/[environmentId]/integrations/notion/constants";
|
} from "@/app/(app)/environments/[environmentId]/integrations/notion/constants";
|
||||||
import { questionTypes } from "@/app/lib/questions";
|
import { questionTypes } from "@/app/lib/questions";
|
||||||
import NotionLogo from "@/images/notion.png";
|
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 * as DropdownMenu from "@radix-ui/react-dropdown-menu";
|
||||||
|
import { ChevronDownIcon, PlusIcon, RefreshCcwIcon, XIcon } from "lucide-react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import React, { useEffect, useMemo, useState } from "react";
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
@@ -402,7 +402,7 @@ export default function AddIntegrationModal({
|
|||||||
mapping.length > 1 ? "visible" : "invisible"
|
mapping.length > 1 ? "visible" : "invisible"
|
||||||
}`}
|
}`}
|
||||||
onClick={deleteRow}>
|
onClick={deleteRow}>
|
||||||
<XMarkIcon className="h-5 w-5 text-red-500" />
|
<XIcon className="h-5 w-5 text-red-500" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -582,7 +582,7 @@ const DropdownSelector = ({
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
refetch();
|
refetch();
|
||||||
}}>
|
}}>
|
||||||
<ArrowPathIcon className="h-5 w-5 font-bold text-slate-500" />
|
<RefreshCcwIcon className="h-5 w-5 font-bold text-slate-500" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+1
-1
@@ -4,8 +4,8 @@ import { triggers } from "@/app/(app)/environments/[environmentId]/integrations/
|
|||||||
import SurveyCheckboxGroup from "@/app/(app)/environments/[environmentId]/integrations/webhooks/components/SurveyCheckboxGroup";
|
import SurveyCheckboxGroup from "@/app/(app)/environments/[environmentId]/integrations/webhooks/components/SurveyCheckboxGroup";
|
||||||
import TriggerCheckboxGroup from "@/app/(app)/environments/[environmentId]/integrations/webhooks/components/TriggerCheckboxGroup";
|
import TriggerCheckboxGroup from "@/app/(app)/environments/[environmentId]/integrations/webhooks/components/TriggerCheckboxGroup";
|
||||||
import { testEndpoint } from "@/app/(app)/environments/[environmentId]/integrations/webhooks/components/testEndpoint";
|
import { testEndpoint } from "@/app/(app)/environments/[environmentId]/integrations/webhooks/components/testEndpoint";
|
||||||
import { TrashIcon } from "@heroicons/react/24/outline";
|
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
import { TrashIcon } from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ExclamationTriangleIcon } from "@heroicons/react/24/solid";
|
import { AlertTriangleIcon } from "lucide-react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
|
|
||||||
import { Button } from "@formbricks/ui/Button";
|
import { Button } from "@formbricks/ui/Button";
|
||||||
@@ -46,7 +46,7 @@ export default function AddMemberModal({ open, setOpen, onSubmit }: MemberModalP
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center rounded-lg border border-slate-200 bg-slate-100 p-2 text-sm text-slate-700">
|
<div className="flex items-center rounded-lg border border-slate-200 bg-slate-100 p-2 text-sm text-slate-700">
|
||||||
<ExclamationTriangleIcon className="mx-3 h-12 w-12 text-amber-500" />
|
<AlertTriangleIcon className="mx-3 h-12 w-12 text-amber-500" />
|
||||||
<p>
|
<p>
|
||||||
For security reasons, the API key will only be <strong>shown once</strong> after creation.
|
For security reasons, the API key will only be <strong>shown once</strong> after creation.
|
||||||
Please copy it to your destination right away.
|
Please copy it to your destination right away.
|
||||||
|
|||||||
+1
-2
@@ -1,7 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { TrashIcon } from "@heroicons/react/24/outline";
|
import { FilesIcon, TrashIcon } from "lucide-react";
|
||||||
import { FilesIcon } from "lucide-react";
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
|
|
||||||
|
|||||||
+15
-15
@@ -1,21 +1,21 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ChevronDownIcon } from "@heroicons/react/20/solid";
|
import clsx from "clsx";
|
||||||
import {
|
import {
|
||||||
AdjustmentsVerticalIcon,
|
BellRingIcon,
|
||||||
BellAlertIcon,
|
|
||||||
BoltIcon,
|
BoltIcon,
|
||||||
|
BrushIcon,
|
||||||
|
ChevronDownIcon,
|
||||||
CreditCardIcon,
|
CreditCardIcon,
|
||||||
DocumentCheckIcon,
|
FileCheckIcon,
|
||||||
DocumentMagnifyingGlassIcon,
|
FileSearch2Icon,
|
||||||
HashtagIcon,
|
HashIcon,
|
||||||
KeyIcon,
|
KeyIcon,
|
||||||
LinkIcon,
|
LinkIcon,
|
||||||
PaintBrushIcon,
|
SlidersIcon,
|
||||||
UserCircleIcon,
|
UserCircleIcon,
|
||||||
UsersIcon,
|
UsersIcon,
|
||||||
} from "@heroicons/react/24/solid";
|
} from "lucide-react";
|
||||||
import clsx from "clsx";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
@@ -76,7 +76,7 @@ export default function SettingsNavbar({
|
|||||||
{
|
{
|
||||||
name: "Notifications",
|
name: "Notifications",
|
||||||
href: `/environments/${environmentId}/settings/notifications`,
|
href: `/environments/${environmentId}/settings/notifications`,
|
||||||
icon: BellAlertIcon,
|
icon: BellRingIcon,
|
||||||
current: pathname?.includes("/notifications"),
|
current: pathname?.includes("/notifications"),
|
||||||
hidden: false,
|
hidden: false,
|
||||||
},
|
},
|
||||||
@@ -89,14 +89,14 @@ export default function SettingsNavbar({
|
|||||||
{
|
{
|
||||||
name: "Settings",
|
name: "Settings",
|
||||||
href: `/environments/${environmentId}/settings/product`,
|
href: `/environments/${environmentId}/settings/product`,
|
||||||
icon: AdjustmentsVerticalIcon,
|
icon: SlidersIcon,
|
||||||
current: pathname?.includes("/product"),
|
current: pathname?.includes("/product"),
|
||||||
hidden: false,
|
hidden: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Look & Feel",
|
name: "Look & Feel",
|
||||||
href: `/environments/${environmentId}/settings/lookandfeel`,
|
href: `/environments/${environmentId}/settings/lookandfeel`,
|
||||||
icon: PaintBrushIcon,
|
icon: BrushIcon,
|
||||||
current: pathname?.includes("/lookandfeel"),
|
current: pathname?.includes("/lookandfeel"),
|
||||||
hidden: isViewer,
|
hidden: isViewer,
|
||||||
},
|
},
|
||||||
@@ -110,7 +110,7 @@ export default function SettingsNavbar({
|
|||||||
{
|
{
|
||||||
name: "Tags",
|
name: "Tags",
|
||||||
href: `/environments/${environmentId}/settings/tags`,
|
href: `/environments/${environmentId}/settings/tags`,
|
||||||
icon: HashtagIcon,
|
icon: HashIcon,
|
||||||
current: pathname?.includes("/tags"),
|
current: pathname?.includes("/tags"),
|
||||||
hidden: isViewer,
|
hidden: isViewer,
|
||||||
},
|
},
|
||||||
@@ -150,14 +150,14 @@ export default function SettingsNavbar({
|
|||||||
{
|
{
|
||||||
name: "Setup Checklist",
|
name: "Setup Checklist",
|
||||||
href: `/environments/${environmentId}/settings/setup`,
|
href: `/environments/${environmentId}/settings/setup`,
|
||||||
icon: DocumentCheckIcon,
|
icon: FileCheckIcon,
|
||||||
current: pathname?.includes("/setup"),
|
current: pathname?.includes("/setup"),
|
||||||
hidden: false,
|
hidden: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Documentation",
|
name: "Documentation",
|
||||||
href: "https://formbricks.com/docs",
|
href: "https://formbricks.com/docs",
|
||||||
icon: DocumentMagnifyingGlassIcon,
|
icon: FileSearch2Icon,
|
||||||
target: "_blank",
|
target: "_blank",
|
||||||
hidden: false,
|
hidden: false,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export default async function SettingsLayout({ children, params }) {
|
|||||||
membershipRole={currentUserMembership?.role}
|
membershipRole={currentUserMembership?.role}
|
||||||
/>
|
/>
|
||||||
<div className="w-full md:ml-64">
|
<div className="w-full md:ml-64">
|
||||||
<div className="px-20 pb-6 pt-14 md:pt-6">
|
<div className="max-w-4xl px-20 pb-6 pt-14 md:pt-6">
|
||||||
<div>{children}</div>
|
<div>{children}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
-406
@@ -1,406 +0,0 @@
|
|||||||
"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;
|
|
||||||
-261
@@ -1,261 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -18,7 +18,6 @@ import { EditBrandColor } from "./components/EditBrandColor";
|
|||||||
import { EditFormbricksBranding } from "./components/EditBranding";
|
import { EditFormbricksBranding } from "./components/EditBranding";
|
||||||
import { EditHighlightBorder } from "./components/EditHighlightBorder";
|
import { EditHighlightBorder } from "./components/EditHighlightBorder";
|
||||||
import { EditPlacement } from "./components/EditPlacement";
|
import { EditPlacement } from "./components/EditPlacement";
|
||||||
import UnifiedStyling from "./components/UnifiedStyling";
|
|
||||||
|
|
||||||
export default async function ProfileSettingsPage({ params }: { params: { environmentId: string } }) {
|
export default async function ProfileSettingsPage({ params }: { params: { environmentId: string } }) {
|
||||||
const [session, team, product] = await Promise.all([
|
const [session, team, product] = await Promise.all([
|
||||||
@@ -51,24 +50,19 @@ export default async function ProfileSettingsPage({ params }: { params: { enviro
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<SettingsTitle title="Look & Feel" />
|
<SettingsTitle title="Look & Feel" />
|
||||||
<SettingsCard
|
<SettingsCard title="Brand Color" description="Match the surveys with your user interface.">
|
||||||
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
|
<EditBrandColor
|
||||||
product={product}
|
product={product}
|
||||||
isBrandColorDisabled={isBrandColorEditDisabled}
|
isBrandColorDisabled={isBrandColorEditDisabled}
|
||||||
environmentId={params.environmentId}
|
environmentId={params.environmentId}
|
||||||
/>
|
/>
|
||||||
</SettingsCard> */}
|
</SettingsCard>
|
||||||
<SettingsCard
|
<SettingsCard
|
||||||
title="In-app Survey Placement"
|
title="In-app Survey Placement"
|
||||||
description="Change where surveys will be shown in your web app.">
|
description="Change where surveys will be shown in your web app.">
|
||||||
<EditPlacement product={product} environmentId={params.environmentId} />
|
<EditPlacement product={product} environmentId={params.environmentId} />
|
||||||
</SettingsCard>
|
</SettingsCard>
|
||||||
{/* <SettingsCard
|
<SettingsCard
|
||||||
noPadding
|
noPadding
|
||||||
title="Highlight Border"
|
title="Highlight Border"
|
||||||
description="Make sure your users notice the survey you display">
|
description="Make sure your users notice the survey you display">
|
||||||
@@ -77,7 +71,7 @@ export default async function ProfileSettingsPage({ params }: { params: { enviro
|
|||||||
defaultBrandColor={DEFAULT_BRAND_COLOR}
|
defaultBrandColor={DEFAULT_BRAND_COLOR}
|
||||||
environmentId={params.environmentId}
|
environmentId={params.environmentId}
|
||||||
/>
|
/>
|
||||||
</SettingsCard> */}
|
</SettingsCard>
|
||||||
<SettingsCard
|
<SettingsCard
|
||||||
title="Formbricks Branding"
|
title="Formbricks Branding"
|
||||||
description="We love your support but understand if you toggle it off.">
|
description="We love your support but understand if you toggle it off.">
|
||||||
|
|||||||
+2
-2
@@ -7,7 +7,7 @@ import {
|
|||||||
resendInviteAction,
|
resendInviteAction,
|
||||||
} from "@/app/(app)/environments/[environmentId]/settings/members/actions";
|
} from "@/app/(app)/environments/[environmentId]/settings/members/actions";
|
||||||
import ShareInviteModal from "@/app/(app)/environments/[environmentId]/settings/members/components/ShareInviteModal";
|
import ShareInviteModal from "@/app/(app)/environments/[environmentId]/settings/members/components/ShareInviteModal";
|
||||||
import { PaperAirplaneIcon, ShareIcon, TrashIcon } from "@heroicons/react/24/outline";
|
import { SendHorizonalIcon, ShareIcon, TrashIcon } from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import React, { useMemo, useState } from "react";
|
import React, { useMemo, useState } from "react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
@@ -127,7 +127,7 @@ export default function MemberActions({ team, member, invite, showDeleteButton }
|
|||||||
handleResendInvite();
|
handleResendInvite();
|
||||||
}}
|
}}
|
||||||
id="resendInviteButton">
|
id="resendInviteButton">
|
||||||
<PaperAirplaneIcon className="h-5 w-5 text-slate-700 hover:text-slate-500" />
|
<SendHorizonalIcon className="h-5 w-5 text-slate-700 hover:text-slate-500" />
|
||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent className="TooltipContent" sideOffset={5}>
|
<TooltipContent className="TooltipContent" sideOffset={5}>
|
||||||
|
|||||||
+2
-3
@@ -1,7 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { CheckIcon } from "@heroicons/react/24/outline";
|
import { CheckIcon, CopyIcon } from "lucide-react";
|
||||||
import { DocumentDuplicateIcon } from "@heroicons/react/24/solid";
|
|
||||||
import { useRef } from "react";
|
import { useRef } from "react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
|
|
||||||
@@ -59,7 +58,7 @@ export default function ShareInviteModal({ inviteToken, open, setOpen }: ShareIn
|
|||||||
}}
|
}}
|
||||||
title="Copy invite link to clipboard"
|
title="Copy invite link to clipboard"
|
||||||
aria-label="Copy invite link to clipboard"
|
aria-label="Copy invite link to clipboard"
|
||||||
EndIcon={DocumentDuplicateIcon}>
|
EndIcon={CopyIcon}>
|
||||||
Copy URL
|
Copy URL
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+3
-3
@@ -1,4 +1,4 @@
|
|||||||
import { QuestionMarkCircleIcon, UsersIcon } from "@heroicons/react/24/solid";
|
import { HelpCircleIcon, UsersIcon } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
import { TUser } from "@formbricks/types/user";
|
import { TUser } from "@formbricks/types/user";
|
||||||
@@ -51,9 +51,9 @@ export default function EditAlerts({
|
|||||||
<TooltipProvider delayDuration={50}>
|
<TooltipProvider delayDuration={50}>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger>
|
<TooltipTrigger>
|
||||||
<div className="col-span-1 flex cursor-default items-center justify-center">
|
<div className="col-span-1 flex cursor-default items-center justify-center space-x-2">
|
||||||
<span className="">Every Response</span>
|
<span className="">Every Response</span>
|
||||||
<QuestionMarkCircleIcon className="h-4 w-4 flex-shrink-0 text-slate-500" />
|
<HelpCircleIcon className="h-4 w-4 flex-shrink-0 text-slate-500" />
|
||||||
</div>
|
</div>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>Sends complete responses, no partials.</TooltipContent>
|
<TooltipContent>Sends complete responses, no partials.</TooltipContent>
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import { UsersIcon } from "@heroicons/react/24/solid";
|
import { UsersIcon } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
import { TUser } from "@formbricks/types/user";
|
import { TUser } from "@formbricks/types/user";
|
||||||
|
|||||||
@@ -40,6 +40,23 @@ export const updateProductAction = async (
|
|||||||
throw new AuthorizationError("Not authorized");
|
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);
|
const updatedProduct = await updateProduct(productId, data);
|
||||||
return updatedProduct;
|
return updatedProduct;
|
||||||
};
|
};
|
||||||
|
|||||||
-1
@@ -42,7 +42,6 @@ if (typeof window !== "undefined") {
|
|||||||
formbricks.init({
|
formbricks.init({
|
||||||
environmentId: "${environmentId}",
|
environmentId: "${environmentId}",
|
||||||
apiHost: "${webAppUrl}",
|
apiHost: "${webAppUrl}",
|
||||||
debug: true, // remove when in production
|
|
||||||
});
|
});
|
||||||
}`}</CodeBlock>
|
}`}</CodeBlock>
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -6,7 +6,7 @@ import {
|
|||||||
updateTagNameAction,
|
updateTagNameAction,
|
||||||
} from "@/app/(app)/environments/[environmentId]/settings/tags/actions";
|
} from "@/app/(app)/environments/[environmentId]/settings/tags/actions";
|
||||||
import MergeTagsCombobox from "@/app/(app)/environments/[environmentId]/settings/tags/components/MergeTagsCombobox";
|
import MergeTagsCombobox from "@/app/(app)/environments/[environmentId]/settings/tags/components/MergeTagsCombobox";
|
||||||
import { ExclamationCircleIcon } from "@heroicons/react/24/solid";
|
import { AlertCircleIcon } from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { toast } from "react-hot-toast";
|
import { toast } from "react-hot-toast";
|
||||||
@@ -69,7 +69,7 @@ const SingleTag: React.FC<{
|
|||||||
if (error?.message.includes("Unique constraint failed on the fields")) {
|
if (error?.message.includes("Unique constraint failed on the fields")) {
|
||||||
toast.error("Tag already exists", {
|
toast.error("Tag already exists", {
|
||||||
duration: 2000,
|
duration: 2000,
|
||||||
icon: <ExclamationCircleIcon className="h-5 w-5 text-orange-500" />,
|
icon: <AlertCircleIcon className="h-5 w-5 text-orange-500" />,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
toast.error(error?.message ?? "Something went wrong", {
|
toast.error(error?.message ?? "Something went wrong", {
|
||||||
|
|||||||
+28
-2
@@ -4,10 +4,10 @@ import { getServerSession } from "next-auth";
|
|||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
|
|
||||||
import { authOptions } from "@formbricks/lib/authOptions";
|
import { authOptions } from "@formbricks/lib/authOptions";
|
||||||
import { getResponses } from "@formbricks/lib/response/service";
|
import { getResponseCountBySurveyId, getResponses, getSurveySummary } from "@formbricks/lib/response/service";
|
||||||
import { canUserAccessSurvey } from "@formbricks/lib/survey/auth";
|
import { canUserAccessSurvey } from "@formbricks/lib/survey/auth";
|
||||||
import { AuthorizationError } from "@formbricks/types/errors";
|
import { AuthorizationError } from "@formbricks/types/errors";
|
||||||
import { TResponse, TResponseFilterCriteria } from "@formbricks/types/responses";
|
import { TResponse, TResponseFilterCriteria, TSurveySummary } from "@formbricks/types/responses";
|
||||||
|
|
||||||
export default async function revalidateSurveyIdPath(environmentId: string, surveyId: string) {
|
export default async function revalidateSurveyIdPath(environmentId: string, surveyId: string) {
|
||||||
revalidatePath(`/environments/${environmentId}/surveys/${surveyId}`);
|
revalidatePath(`/environments/${environmentId}/surveys/${surveyId}`);
|
||||||
@@ -45,3 +45,29 @@ export async function getResponsesAction(
|
|||||||
const responses = await getResponses(surveyId, page, batchSize, filterCriteria);
|
const responses = await getResponses(surveyId, page, batchSize, filterCriteria);
|
||||||
return responses;
|
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);
|
||||||
|
};
|
||||||
|
|||||||
+11
-5
@@ -1,5 +1,5 @@
|
|||||||
import revalidateSurveyIdPath from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
|
import revalidateSurveyIdPath from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
|
||||||
import { InboxStackIcon, PresentationChartLineIcon } from "@heroicons/react/24/solid";
|
import { InboxIcon, PresentationIcon } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
import { cn } from "@formbricks/lib/cn";
|
import { cn } from "@formbricks/lib/cn";
|
||||||
@@ -8,20 +8,26 @@ interface SurveyResultsTabProps {
|
|||||||
activeId: string;
|
activeId: string;
|
||||||
environmentId: string;
|
environmentId: string;
|
||||||
surveyId: string;
|
surveyId: string;
|
||||||
|
responseCount: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SurveyResultsTab({ activeId, environmentId, surveyId }: SurveyResultsTabProps) {
|
export default function SurveyResultsTab({
|
||||||
|
activeId,
|
||||||
|
environmentId,
|
||||||
|
surveyId,
|
||||||
|
responseCount,
|
||||||
|
}: SurveyResultsTabProps) {
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{
|
{
|
||||||
id: "summary",
|
id: "summary",
|
||||||
label: "Summary",
|
label: "Summary",
|
||||||
icon: <PresentationChartLineIcon />,
|
icon: <PresentationIcon className="h-5 w-5" />,
|
||||||
href: `/environments/${environmentId}/surveys/${surveyId}/summary?referer=true`,
|
href: `/environments/${environmentId}/surveys/${surveyId}/summary?referer=true`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "responses",
|
id: "responses",
|
||||||
label: "Responses",
|
label: `Responses ${responseCount !== null ? `(${responseCount})` : ""}`,
|
||||||
icon: <InboxStackIcon />,
|
icon: <InboxIcon className="h-5 w-5" />,
|
||||||
href: `/environments/${environmentId}/surveys/${surveyId}/responses?referer=true`,
|
href: `/environments/${environmentId}/surveys/${surveyId}/responses?referer=true`,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
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 };
|
|
||||||
};
|
|
||||||
+40
-19
@@ -1,7 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
|
import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
|
||||||
import { getResponsesAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
|
import {
|
||||||
|
getResponseCountAction,
|
||||||
|
getResponsesAction,
|
||||||
|
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
|
||||||
import SurveyResultsTabs from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyResultsTabs";
|
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 ResponseTimeline from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTimeline";
|
||||||
import CustomFilter from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter";
|
import CustomFilter from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter";
|
||||||
@@ -47,6 +50,7 @@ const ResponsePage = ({
|
|||||||
responsesPerPage,
|
responsesPerPage,
|
||||||
membershipRole,
|
membershipRole,
|
||||||
}: ResponsePageProps) => {
|
}: ResponsePageProps) => {
|
||||||
|
const [responseCount, setResponseCount] = useState<number | null>(null);
|
||||||
const [responses, setResponses] = useState<TResponse[]>([]);
|
const [responses, setResponses] = useState<TResponse[]>([]);
|
||||||
const [page, setPage] = useState<number>(1);
|
const [page, setPage] = useState<number>(1);
|
||||||
const [hasMore, setHasMore] = useState<boolean>(true);
|
const [hasMore, setHasMore] = useState<boolean>(true);
|
||||||
@@ -64,6 +68,27 @@ const ResponsePage = ({
|
|||||||
return checkForRecallInHeadline(survey);
|
return checkForRecallInHeadline(survey);
|
||||||
}, [survey]);
|
}, [survey]);
|
||||||
|
|
||||||
|
const fetchNextPage = useCallback(async () => {
|
||||||
|
const newPage = page + 1;
|
||||||
|
const newResponses = await getResponsesAction(surveyId, newPage, responsesPerPage, filters);
|
||||||
|
if (newResponses.length === 0 || newResponses.length < responsesPerPage) {
|
||||||
|
setHasMore(false);
|
||||||
|
}
|
||||||
|
setResponses([...responses, ...newResponses]);
|
||||||
|
setPage(newPage);
|
||||||
|
}, [filters, page, responses, responsesPerPage, surveyId]);
|
||||||
|
|
||||||
|
const deleteResponse = (responseId: string) => {
|
||||||
|
setResponses(responses.filter((response) => response.id !== responseId));
|
||||||
|
if (responseCount) {
|
||||||
|
setResponseCount(responseCount - 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateResponse = (responseId: string, updatedResponse: TResponse) => {
|
||||||
|
setResponses(responses.map((response) => (response.id === responseId ? updatedResponse : response)));
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!searchParams?.get("referer")) {
|
if (!searchParams?.get("referer")) {
|
||||||
resetState();
|
resetState();
|
||||||
@@ -81,27 +106,18 @@ const ResponsePage = ({
|
|||||||
fetchInitialResponses();
|
fetchInitialResponses();
|
||||||
}, [surveyId, filters, responsesPerPage]);
|
}, [surveyId, filters, responsesPerPage]);
|
||||||
|
|
||||||
const fetchNextPage = useCallback(async () => {
|
useEffect(() => {
|
||||||
const newPage = page + 1;
|
const handleResponsesCount = async () => {
|
||||||
const newResponses = await getResponsesAction(surveyId, newPage, responsesPerPage, filters);
|
const responseCount = await getResponseCountAction(surveyId, filters);
|
||||||
if (newResponses.length === 0 || newResponses.length < responsesPerPage) {
|
setResponseCount(responseCount);
|
||||||
setHasMore(false);
|
};
|
||||||
}
|
handleResponsesCount();
|
||||||
setResponses([...responses, ...newResponses]);
|
}, [filters, surveyId]);
|
||||||
setPage(newPage);
|
|
||||||
}, [filters, page, responses, responsesPerPage, surveyId]);
|
|
||||||
|
|
||||||
const deleteResponse = (responseId: string) => {
|
|
||||||
setResponses(responses.filter((response) => response.id !== responseId));
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateResponse = (responseId: string, updatedResponse: TResponse) => {
|
|
||||||
setResponses(responses.map((response) => (response.id === responseId ? updatedResponse : response)));
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPage(1);
|
setPage(1);
|
||||||
setHasMore(true);
|
setHasMore(true);
|
||||||
|
setResponses([]);
|
||||||
}, [filters]);
|
}, [filters]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -119,7 +135,12 @@ const ResponsePage = ({
|
|||||||
<CustomFilter environmentTags={environmentTags} attributes={attributes} survey={survey} />
|
<CustomFilter environmentTags={environmentTags} attributes={attributes} survey={survey} />
|
||||||
<ResultsShareButton survey={survey} webAppUrl={webAppUrl} product={product} user={user} />
|
<ResultsShareButton survey={survey} webAppUrl={webAppUrl} product={product} user={user} />
|
||||||
</div>
|
</div>
|
||||||
<SurveyResultsTabs activeId="responses" environmentId={environment.id} surveyId={surveyId} />
|
<SurveyResultsTabs
|
||||||
|
activeId="responses"
|
||||||
|
environmentId={environment.id}
|
||||||
|
surveyId={surveyId}
|
||||||
|
responseCount={responseCount}
|
||||||
|
/>
|
||||||
<ResponseTimeline
|
<ResponseTimeline
|
||||||
environment={environment}
|
environment={environment}
|
||||||
surveyId={surveyId}
|
surveyId={surveyId}
|
||||||
|
|||||||
+6
@@ -3,6 +3,8 @@
|
|||||||
import EmptyInAppSurveys from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/EmptyInAppSurveys";
|
import EmptyInAppSurveys from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/EmptyInAppSurveys";
|
||||||
import React, { useEffect, useRef } from "react";
|
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 { TEnvironment } from "@formbricks/types/environment";
|
||||||
import { TResponse } from "@formbricks/types/responses";
|
import { TResponse } from "@formbricks/types/responses";
|
||||||
import { TSurvey } from "@formbricks/types/surveys";
|
import { TSurvey } from "@formbricks/types/surveys";
|
||||||
@@ -60,6 +62,9 @@ export default function ResponseTimeline({
|
|||||||
};
|
};
|
||||||
}, [fetchNextPage, hasMore]);
|
}, [fetchNextPage, hasMore]);
|
||||||
|
|
||||||
|
const { membershipRole } = useMembershipRole(survey.environmentId);
|
||||||
|
const { isViewer } = getAccessFlags(membershipRole);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{survey.type === "web" && responses.length === 0 && !environment.widgetSetupCompleted ? (
|
{survey.type === "web" && responses.length === 0 && !environment.widgetSetupCompleted ? (
|
||||||
@@ -84,6 +89,7 @@ export default function ResponseTimeline({
|
|||||||
environment={environment}
|
environment={environment}
|
||||||
updateResponse={updateResponse}
|
updateResponse={updateResponse}
|
||||||
deleteResponse={deleteResponse}
|
deleteResponse={deleteResponse}
|
||||||
|
isViewer={isViewer}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+3
-2
@@ -9,6 +9,7 @@ import { authOptions } from "@formbricks/lib/authOptions";
|
|||||||
import { sendEmbedSurveyPreviewEmail } from "@formbricks/lib/emails/emails";
|
import { sendEmbedSurveyPreviewEmail } from "@formbricks/lib/emails/emails";
|
||||||
import { canUserAccessSurvey } from "@formbricks/lib/survey/auth";
|
import { canUserAccessSurvey } from "@formbricks/lib/survey/auth";
|
||||||
import { getSurvey, updateSurvey } from "@formbricks/lib/survey/service";
|
import { getSurvey, updateSurvey } from "@formbricks/lib/survey/service";
|
||||||
|
import { formatSurveyDateFields } from "@formbricks/lib/survey/util";
|
||||||
import { AuthenticationError, AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
import { AuthenticationError, AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||||
|
|
||||||
type TSendEmailActionArgs = {
|
type TSendEmailActionArgs = {
|
||||||
@@ -54,7 +55,7 @@ export async function generateResultShareUrlAction(surveyId: string): Promise<st
|
|||||||
20
|
20
|
||||||
)();
|
)();
|
||||||
|
|
||||||
await updateSurvey({ ...survey, resultShareKey });
|
await updateSurvey({ ...formatSurveyDateFields(survey), resultShareKey });
|
||||||
|
|
||||||
return resultShareKey;
|
return resultShareKey;
|
||||||
}
|
}
|
||||||
@@ -86,7 +87,7 @@ export async function deleteResultShareUrlAction(surveyId: string): Promise<void
|
|||||||
throw new ResourceNotFoundError("Survey", surveyId);
|
throw new ResourceNotFoundError("Survey", surveyId);
|
||||||
}
|
}
|
||||||
|
|
||||||
await updateSurvey({ ...survey, resultShareKey: null });
|
await updateSurvey({ ...formatSurveyDateFields(survey), resultShareKey: null });
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getEmailHtmlAction = async (surveyId: string) => {
|
export const getEmailHtmlAction = async (surveyId: string) => {
|
||||||
|
|||||||
+8
-25
@@ -1,34 +1,17 @@
|
|||||||
import Headline from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/Headline";
|
import Headline from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/Headline";
|
||||||
import { questionTypes } from "@/app/lib/questions";
|
import { questionTypes } from "@/app/lib/questions";
|
||||||
import { InboxStackIcon } from "@heroicons/react/24/solid";
|
import { InboxIcon } from "lucide-react";
|
||||||
import { useMemo } from "react";
|
|
||||||
|
|
||||||
import type { TSurveyQuestionSummary } from "@formbricks/types/surveys";
|
import { TSurveySummaryCta } from "@formbricks/types/responses";
|
||||||
import { TSurveyCTAQuestion } from "@formbricks/types/surveys";
|
|
||||||
import { ProgressBar } from "@formbricks/ui/ProgressBar";
|
import { ProgressBar } from "@formbricks/ui/ProgressBar";
|
||||||
|
|
||||||
interface CTASummaryProps {
|
interface CTASummaryProps {
|
||||||
questionSummary: TSurveyQuestionSummary<TSurveyCTAQuestion>;
|
questionSummary: TSurveySummaryCta;
|
||||||
}
|
|
||||||
|
|
||||||
interface ChoiceResult {
|
|
||||||
count: number;
|
|
||||||
percentage: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CTASummary({ questionSummary }: CTASummaryProps) {
|
export default function CTASummary({ questionSummary }: CTASummaryProps) {
|
||||||
const questionTypeInfo = questionTypes.find((type) => type.id === questionSummary.question.type);
|
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 (
|
return (
|
||||||
<div className=" rounded-lg border border-slate-200 bg-slate-50 shadow-sm">
|
<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">
|
<div className="space-y-2 px-4 pb-5 pt-6 md:px-6">
|
||||||
@@ -40,8 +23,8 @@ export default function CTASummary({ questionSummary }: CTASummaryProps) {
|
|||||||
{questionTypeInfo ? questionTypeInfo.label : "Unknown Question Type"}
|
{questionTypeInfo ? questionTypeInfo.label : "Unknown Question Type"}
|
||||||
</div>
|
</div>
|
||||||
<div className=" flex items-center rounded-lg bg-slate-100 p-2">
|
<div className=" flex items-center rounded-lg bg-slate-100 p-2">
|
||||||
<InboxStackIcon className="mr-2 h-4 w-4 " />
|
<InboxIcon className="mr-2 h-4 w-4 " />
|
||||||
{ctr.count} responses
|
{questionSummary.responseCount} responses
|
||||||
</div>
|
</div>
|
||||||
{!questionSummary.question.required && (
|
{!questionSummary.question.required && (
|
||||||
<div className="flex items-center rounded-lg bg-slate-100 p-2">Optional</div>
|
<div className="flex items-center rounded-lg bg-slate-100 p-2">Optional</div>
|
||||||
@@ -54,15 +37,15 @@ export default function CTASummary({ questionSummary }: CTASummaryProps) {
|
|||||||
<p className="font-semibold text-slate-700">Clickthrough Rate (CTR)</p>
|
<p className="font-semibold text-slate-700">Clickthrough Rate (CTR)</p>
|
||||||
<div>
|
<div>
|
||||||
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
|
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
|
||||||
{Math.round(ctr.percentage * 100)}%
|
{Math.round(questionSummary.ctr.percentage)}%
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="flex w-32 items-end justify-end text-slate-600">
|
<p className="flex w-32 items-end justify-end text-slate-600">
|
||||||
{ctr.count} {ctr.count === 1 ? "response" : "responses"}
|
{questionSummary.ctr.count} {questionSummary.ctr.count === 1 ? "response" : "responses"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<ProgressBar barColor="bg-brand" progress={ctr.percentage} />
|
<ProgressBar barColor="bg-brand" progress={questionSummary.ctr.percentage / 100} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+41
-48
@@ -1,19 +1,16 @@
|
|||||||
import Headline from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/Headline";
|
import Headline from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/Headline";
|
||||||
import { questionTypes } from "@/app/lib/questions";
|
import { questionTypes } from "@/app/lib/questions";
|
||||||
import { InboxStackIcon } from "@heroicons/react/24/solid";
|
import { InboxIcon } from "lucide-react";
|
||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
import { getPersonIdentifier } from "@formbricks/lib/person/util";
|
import { TSurveySummaryCal } from "@formbricks/types/responses";
|
||||||
import { timeSince } from "@formbricks/lib/time";
|
import { ProgressBar } from "@formbricks/ui/ProgressBar";
|
||||||
import { TSurveyCalQuestion, TSurveyQuestionSummary } from "@formbricks/types/surveys";
|
|
||||||
import { PersonAvatar } from "@formbricks/ui/Avatars";
|
|
||||||
|
|
||||||
interface CalSummaryProps {
|
interface CalSummaryProps {
|
||||||
questionSummary: TSurveyQuestionSummary<TSurveyCalQuestion>;
|
questionSummary: TSurveySummaryCal;
|
||||||
environmentId: string;
|
environmentId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CalSummary({ questionSummary, environmentId }: CalSummaryProps) {
|
export default function CalSummary({ questionSummary }: CalSummaryProps) {
|
||||||
const questionTypeInfo = questionTypes.find((type) => type.id === questionSummary.question.type);
|
const questionTypeInfo = questionTypes.find((type) => type.id === questionSummary.question.type);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -27,51 +24,47 @@ export default function CalSummary({ questionSummary, environmentId }: CalSummar
|
|||||||
{questionTypeInfo ? questionTypeInfo.label : "Unknown Question Type"} Question
|
{questionTypeInfo ? questionTypeInfo.label : "Unknown Question Type"} Question
|
||||||
</div>
|
</div>
|
||||||
<div className=" flex items-center rounded-lg bg-slate-100 p-2">
|
<div className=" flex items-center rounded-lg bg-slate-100 p-2">
|
||||||
<InboxStackIcon className="mr-2 h-4 w-4" />
|
<InboxIcon className="mr-2 h-4 w-4" />
|
||||||
{questionSummary.responses.length} Responses
|
{questionSummary.responseCount} Responses
|
||||||
</div>
|
</div>
|
||||||
|
{!questionSummary.question.required && (
|
||||||
|
<div className="flex items-center rounded-lg bg-slate-100 p-2">Optional</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-b-lg bg-white ">
|
<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 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>
|
||||||
<div className="pl-4 md:pl-6">User</div>
|
<div className="text flex justify-between px-2 pb-2">
|
||||||
<div className="col-span-2 pl-4 md:pl-6">Response</div>
|
<div className="mr-8 flex space-x-1">
|
||||||
<div className="px-4 md:px-6">Time</div>
|
<p className="font-semibold text-slate-700">Booked</p>
|
||||||
</div>
|
<div>
|
||||||
{questionSummary.responses.map((response) => {
|
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
|
||||||
const displayIdentifier = response.person ? getPersonIdentifier(response.person) : null;
|
{Math.round(questionSummary.booked.percentage)}%
|
||||||
return (
|
</p>
|
||||||
<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>
|
||||||
<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>
|
</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>
|
||||||
|
<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>
|
||||||
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+14
-36
@@ -1,54 +1,30 @@
|
|||||||
import Headline from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/Headline";
|
import Headline from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/Headline";
|
||||||
import { questionTypes } from "@/app/lib/questions";
|
import { questionTypes } from "@/app/lib/questions";
|
||||||
import { InboxStackIcon } from "@heroicons/react/24/solid";
|
import { InboxIcon } from "lucide-react";
|
||||||
import { useMemo } from "react";
|
|
||||||
|
|
||||||
import type { TSurveyQuestionSummary } from "@formbricks/types/surveys";
|
import { TSurveySummaryConsent } from "@formbricks/types/responses";
|
||||||
import { TSurveyConsentQuestion } from "@formbricks/types/surveys";
|
|
||||||
import { ProgressBar } from "@formbricks/ui/ProgressBar";
|
import { ProgressBar } from "@formbricks/ui/ProgressBar";
|
||||||
|
|
||||||
interface ConsentSummaryProps {
|
interface ConsentSummaryProps {
|
||||||
questionSummary: TSurveyQuestionSummary<TSurveyConsentQuestion>;
|
questionSummary: TSurveySummaryConsent;
|
||||||
}
|
|
||||||
|
|
||||||
interface ChoiceResult {
|
|
||||||
count: number;
|
|
||||||
acceptedCount: number;
|
|
||||||
acceptedPercentage: number;
|
|
||||||
dismissedCount: number;
|
|
||||||
dismissedPercentage: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ConsentSummary({ questionSummary }: ConsentSummaryProps) {
|
export default function ConsentSummary({ questionSummary }: ConsentSummaryProps) {
|
||||||
const questionTypeInfo = questionTypes.find((type) => type.id === questionSummary.question.type);
|
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 (
|
return (
|
||||||
<div className=" rounded-lg border border-slate-200 bg-slate-50 shadow-sm">
|
<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">
|
<div className="space-y-2 px-4 pb-5 pt-6 md:px-6">
|
||||||
<Headline headline={questionSummary.question.headline} />
|
<Headline headline={questionSummary.question.headline} />
|
||||||
|
|
||||||
<div className="flex space-x-2 text-xs font-semibold text-slate-600 md:text-sm">
|
<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">
|
<div className=" flex items-center rounded-lg bg-slate-100 p-2">
|
||||||
{questionTypeInfo && <questionTypeInfo.icon className="mr-2 h-4 w-4 " />}
|
{questionTypeInfo && <questionTypeInfo.icon className="mr-2 h-4 w-4 " />}
|
||||||
{questionTypeInfo ? questionTypeInfo.label : "Unknown Question Type"}
|
{questionTypeInfo ? questionTypeInfo.label : "Unknown Question Type"}
|
||||||
</div>
|
</div>
|
||||||
<div className=" flex items-center rounded-lg bg-slate-100 p-2">
|
<div className=" flex items-center rounded-lg bg-slate-100 p-2">
|
||||||
<InboxStackIcon className="mr-2 h-4 w-4 " />
|
<InboxIcon className="mr-2 h-4 w-4 " />
|
||||||
{ctr.count} responses
|
{questionSummary.responseCount} responses
|
||||||
</div>
|
</div>
|
||||||
{!questionSummary.question.required && (
|
{!questionSummary.question.required && (
|
||||||
<div className="flex items-center rounded-lg bg-slate-100 p-2">Optional</div>
|
<div className="flex items-center rounded-lg bg-slate-100 p-2">Optional</div>
|
||||||
@@ -62,15 +38,16 @@ export default function ConsentSummary({ questionSummary }: ConsentSummaryProps)
|
|||||||
<p className="font-semibold text-slate-700">Accepted</p>
|
<p className="font-semibold text-slate-700">Accepted</p>
|
||||||
<div>
|
<div>
|
||||||
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
|
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
|
||||||
{Math.round(ctr.acceptedPercentage * 100)}%
|
{Math.round(questionSummary.accepted.percentage)}%
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="flex w-32 items-end justify-end text-slate-600">
|
<p className="flex w-32 items-end justify-end text-slate-600">
|
||||||
{ctr.acceptedCount} {ctr.acceptedCount === 1 ? "response" : "responses"}
|
{questionSummary.accepted.count}{" "}
|
||||||
|
{questionSummary.accepted.count === 1 ? "response" : "responses"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<ProgressBar barColor="bg-brand" progress={ctr.acceptedPercentage} />
|
<ProgressBar barColor="bg-brand" progress={questionSummary.accepted.percentage / 100} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text flex justify-between px-2 pb-2">
|
<div className="text flex justify-between px-2 pb-2">
|
||||||
@@ -78,15 +55,16 @@ export default function ConsentSummary({ questionSummary }: ConsentSummaryProps)
|
|||||||
<p className="font-semibold text-slate-700">Dismissed</p>
|
<p className="font-semibold text-slate-700">Dismissed</p>
|
||||||
<div>
|
<div>
|
||||||
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
|
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
|
||||||
{Math.round(ctr.dismissedPercentage * 100)}%
|
{Math.round(questionSummary.dismissed.percentage)}%
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="flex w-32 items-end justify-end text-slate-600">
|
<p className="flex w-32 items-end justify-end text-slate-600">
|
||||||
{ctr.dismissedCount} {ctr.dismissedCount === 1 ? "response" : "responses"}
|
{questionSummary.dismissed.count}{" "}
|
||||||
|
{questionSummary.dismissed.count === 1 ? "response" : "responses"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<ProgressBar barColor="bg-brand" progress={ctr.dismissedPercentage} />
|
<ProgressBar barColor="bg-brand" progress={questionSummary.dismissed.percentage / 100} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+36
-55
@@ -1,28 +1,21 @@
|
|||||||
import Headline from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/Headline";
|
import Headline from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/Headline";
|
||||||
import { questionTypes } from "@/app/lib/questions";
|
import { questionTypes } from "@/app/lib/questions";
|
||||||
import { InboxStackIcon } from "@heroicons/react/24/solid";
|
import { InboxIcon } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
import { getPersonIdentifier } from "@formbricks/lib/person/util";
|
import { getPersonIdentifier } from "@formbricks/lib/person/util";
|
||||||
import { timeSince } from "@formbricks/lib/time";
|
import { timeSince } from "@formbricks/lib/time";
|
||||||
import { formatDateWithOrdinal } from "@formbricks/lib/utils/datetime";
|
import { formatDateWithOrdinal } from "@formbricks/lib/utils/datetime";
|
||||||
import type { TSurveyDateQuestion, TSurveyQuestionSummary } from "@formbricks/types/surveys";
|
import { TSurveySummaryDate } from "@formbricks/types/responses";
|
||||||
import { PersonAvatar } from "@formbricks/ui/Avatars";
|
import { PersonAvatar } from "@formbricks/ui/Avatars";
|
||||||
|
|
||||||
interface DateQuestionSummary {
|
interface DateQuestionSummary {
|
||||||
questionSummary: TSurveyQuestionSummary<TSurveyDateQuestion>;
|
questionSummary: TSurveySummaryDate;
|
||||||
environmentId: string;
|
environmentId: string;
|
||||||
responsesPerPage: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DateQuestionSummary({
|
export default function DateQuestionSummary({ questionSummary, environmentId }: DateQuestionSummary) {
|
||||||
questionSummary,
|
|
||||||
environmentId,
|
|
||||||
responsesPerPage,
|
|
||||||
}: DateQuestionSummary) {
|
|
||||||
const questionTypeInfo = questionTypes.find((type) => type.id === questionSummary.question.type);
|
const questionTypeInfo = questionTypes.find((type) => type.id === questionSummary.question.type);
|
||||||
const [displayCount, setDisplayCount] = useState(responsesPerPage);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-slate-200 bg-slate-50 shadow-sm">
|
<div className="rounded-lg border border-slate-200 bg-slate-50 shadow-sm">
|
||||||
@@ -35,8 +28,8 @@ export default function DateQuestionSummary({
|
|||||||
{questionTypeInfo ? questionTypeInfo.label : "Unknown Question Type"} Question
|
{questionTypeInfo ? questionTypeInfo.label : "Unknown Question Type"} Question
|
||||||
</div>
|
</div>
|
||||||
<div className=" flex items-center rounded-lg bg-slate-100 p-2">
|
<div className=" flex items-center rounded-lg bg-slate-100 p-2">
|
||||||
<InboxStackIcon className="mr-2 h-4 w-4" />
|
<InboxIcon className="mr-2 h-4 w-4" />
|
||||||
{questionSummary.responses.length} Responses
|
{questionSummary.responseCount} Responses
|
||||||
</div>
|
</div>
|
||||||
{!questionSummary.question.required && (
|
{!questionSummary.question.required && (
|
||||||
<div className="flex items-center rounded-lg bg-slate-100 p-2">Optional</div>
|
<div className="flex items-center rounded-lg bg-slate-100 p-2">Optional</div>
|
||||||
@@ -49,51 +42,39 @@ export default function DateQuestionSummary({
|
|||||||
<div className="col-span-2 pl-4 md:pl-6">Response</div>
|
<div className="col-span-2 pl-4 md:pl-6">Response</div>
|
||||||
<div className="px-4 md:px-6">Time</div>
|
<div className="px-4 md:px-6">Time</div>
|
||||||
</div>
|
</div>
|
||||||
{questionSummary.responses.slice(0, displayCount).map((response) => {
|
{questionSummary.samples.map((response) => (
|
||||||
const displayIdentifier = getPersonIdentifier(response.person!);
|
<div
|
||||||
return (
|
key={response.id}
|
||||||
<div
|
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 md:text-base">
|
||||||
key={response.id}
|
<div className="pl-4 md:pl-6">
|
||||||
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 md:text-base">
|
{response.person ? (
|
||||||
<div className="pl-4 md:pl-6">
|
<Link
|
||||||
{response.person ? (
|
className="ph-no-capture group flex items-center"
|
||||||
<Link
|
href={`/environments/${environmentId}/people/${response.person.id}`}>
|
||||||
className="ph-no-capture group flex items-center"
|
<div className="hidden md:flex">
|
||||||
href={`/environments/${environmentId}/people/${response.person.id}`}>
|
<PersonAvatar personId={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>
|
||||||
)}
|
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
|
||||||
</div>
|
{getPersonIdentifier(response.person)}
|
||||||
<div className="ph-no-capture col-span-2 whitespace-pre-wrap pl-6 font-semibold">
|
</p>
|
||||||
{formatDateWithOrdinal(new Date(response.value as string))}
|
</Link>
|
||||||
</div>
|
) : (
|
||||||
<div className="px-4 text-slate-500 md:px-6">{timeSince(response.updatedAt.toISOString())}</div>
|
<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>
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+65
-75
@@ -1,18 +1,16 @@
|
|||||||
import Headline from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/Headline";
|
import Headline from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/Headline";
|
||||||
import { questionTypes } from "@/app/lib/questions";
|
import { questionTypes } from "@/app/lib/questions";
|
||||||
import { InboxStackIcon } from "@heroicons/react/24/solid";
|
import { DownloadIcon, FileIcon, InboxIcon } from "lucide-react";
|
||||||
import { DownloadIcon, FileIcon } from "lucide-react";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
import { getPersonIdentifier } from "@formbricks/lib/person/util";
|
import { getPersonIdentifier } from "@formbricks/lib/person/util";
|
||||||
import { getOriginalFileNameFromUrl } from "@formbricks/lib/storage/utils";
|
import { getOriginalFileNameFromUrl } from "@formbricks/lib/storage/utils";
|
||||||
import { timeSince } from "@formbricks/lib/time";
|
import { timeSince } from "@formbricks/lib/time";
|
||||||
import type { TSurveyQuestionSummary } from "@formbricks/types/surveys";
|
import { TSurveySummaryFileUpload } from "@formbricks/types/responses";
|
||||||
import { TSurveyFileUploadQuestion } from "@formbricks/types/surveys";
|
|
||||||
import { PersonAvatar } from "@formbricks/ui/Avatars";
|
import { PersonAvatar } from "@formbricks/ui/Avatars";
|
||||||
|
|
||||||
interface FileUploadSummaryProps {
|
interface FileUploadSummaryProps {
|
||||||
questionSummary: TSurveyQuestionSummary<TSurveyFileUploadQuestion>;
|
questionSummary: TSurveySummaryFileUpload;
|
||||||
environmentId: string;
|
environmentId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,8 +28,8 @@ export default function FileUploadSummary({ questionSummary, environmentId }: Fi
|
|||||||
{questionTypeInfo ? questionTypeInfo.label : "Unknown Question Type"} Question
|
{questionTypeInfo ? questionTypeInfo.label : "Unknown Question Type"} Question
|
||||||
</div>
|
</div>
|
||||||
<div className=" flex items-center rounded-lg bg-slate-100 p-2">
|
<div className=" flex items-center rounded-lg bg-slate-100 p-2">
|
||||||
<InboxStackIcon className="mr-2 h-4 w-4" />
|
<InboxIcon className="mr-2 h-4 w-4" />
|
||||||
{questionSummary.responses.length} Responses
|
{questionSummary.responseCount} Responses
|
||||||
</div>
|
</div>
|
||||||
{!questionSummary.question.required && (
|
{!questionSummary.question.required && (
|
||||||
<div className="flex items-center rounded-lg bg-slate-100 p-2">Optional</div>
|
<div className="flex items-center rounded-lg bg-slate-100 p-2">Optional</div>
|
||||||
@@ -44,80 +42,72 @@ export default function FileUploadSummary({ questionSummary, environmentId }: Fi
|
|||||||
<div className="col-span-2 pl-4 md:pl-6">Response</div>
|
<div className="col-span-2 pl-4 md:pl-6">Response</div>
|
||||||
<div className="px-4 md:px-6">Time</div>
|
<div className="px-4 md:px-6">Time</div>
|
||||||
</div>
|
</div>
|
||||||
{questionSummary.responses.map((response) => {
|
{questionSummary.files.map((response) => (
|
||||||
const displayIdentifier = response.person ? getPersonIdentifier(response.person) : null;
|
<div
|
||||||
|
key={response.id}
|
||||||
return (
|
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 md:text-base">
|
||||||
<div
|
<div className="pl-4 md:pl-6">
|
||||||
key={response.id}
|
{response.person ? (
|
||||||
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 md:text-base">
|
<Link
|
||||||
<div className="pl-4 md:pl-6">
|
className="ph-no-capture group flex items-center"
|
||||||
{response.person ? (
|
href={`/environments/${environmentId}/people/${response.person.id}`}>
|
||||||
<Link
|
<div className="hidden md:flex">
|
||||||
className="ph-no-capture group flex items-center"
|
<PersonAvatar personId={response.person.id} />
|
||||||
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>
|
||||||
)}
|
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
|
||||||
</div>
|
{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="col-span-2 grid">
|
<div className="col-span-2 grid">
|
||||||
{response.value === "skipped" && (
|
{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">
|
<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>
|
<p className="mt-2 text-sm font-semibold text-slate-500 dark:text-slate-400">skipped</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
))}
|
||||||
|
|
||||||
{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 className="px-4 text-slate-500 md:px-6">
|
||||||
|
{timeSince(new Date(response.updatedAt).toISOString())}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+41
-69
@@ -1,55 +1,32 @@
|
|||||||
import Headline from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/Headline";
|
import Headline from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/Headline";
|
||||||
import { ChatBubbleBottomCenterTextIcon, InboxStackIcon } from "@heroicons/react/24/solid";
|
import { InboxIcon, Link, MessageSquareTextIcon } from "lucide-react";
|
||||||
import { Link } from "lucide-react";
|
import { FC } from "react";
|
||||||
import { FC, useMemo } from "react";
|
|
||||||
|
|
||||||
import { getPersonIdentifier } from "@formbricks/lib/person/util";
|
import { getPersonIdentifier } from "@formbricks/lib/person/util";
|
||||||
import { timeSince } from "@formbricks/lib/time";
|
import { timeSince } from "@formbricks/lib/time";
|
||||||
import { TEnvironment } from "@formbricks/types/environment";
|
import { TEnvironment } from "@formbricks/types/environment";
|
||||||
import { TResponse } from "@formbricks/types/responses";
|
import { TSurveySummaryHiddenField } from "@formbricks/types/responses";
|
||||||
import { TSurvey } from "@formbricks/types/surveys";
|
|
||||||
import { PersonAvatar } from "@formbricks/ui/Avatars";
|
import { PersonAvatar } from "@formbricks/ui/Avatars";
|
||||||
|
|
||||||
interface HiddenFieldsSummaryProps {
|
interface HiddenFieldsSummaryProps {
|
||||||
question: string;
|
|
||||||
survey: TSurvey;
|
|
||||||
responses: TResponse[];
|
|
||||||
environment: TEnvironment;
|
environment: TEnvironment;
|
||||||
|
questionSummary: TSurveySummaryHiddenField;
|
||||||
}
|
}
|
||||||
|
|
||||||
const HiddenFieldsSummary: FC<HiddenFieldsSummaryProps> = ({ environment, responses, survey, question }) => {
|
const HiddenFieldsSummary: FC<HiddenFieldsSummaryProps> = ({ environment, questionSummary }) => {
|
||||||
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 (
|
return (
|
||||||
<div className="rounded-lg border border-slate-200 bg-slate-50 shadow-sm">
|
<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">
|
<div className="space-y-2 px-4 pb-5 pt-6 md:px-6">
|
||||||
<Headline headline={question} />
|
<Headline headline={questionSummary.question} />
|
||||||
|
|
||||||
<div className="flex space-x-2 text-xs font-semibold text-slate-600 md:text-sm">
|
<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 ">
|
<div className="flex items-center rounded-lg bg-slate-100 p-2 ">
|
||||||
<ChatBubbleBottomCenterTextIcon className="mr-2 h-4 w-4" />
|
<MessageSquareTextIcon className="mr-2 h-4 w-4" />
|
||||||
Hidden Field
|
Hidden Field
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center rounded-lg bg-slate-100 p-2 ">
|
<div className="flex items-center rounded-lg bg-slate-100 p-2 ">
|
||||||
<InboxStackIcon className="mr-2 h-4 w-4" />
|
<InboxIcon className="mr-2 h-4 w-4" />
|
||||||
{hiddenFieldResponses?.find((q) => q.question === question)?.responses?.length} Responses
|
{questionSummary.responseCount} {questionSummary.responseCount === 1 ? "Response" : "Responses"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -59,44 +36,39 @@ const HiddenFieldsSummary: FC<HiddenFieldsSummaryProps> = ({ environment, respon
|
|||||||
<div className="col-span-2 pl-4 md:pl-6">Response</div>
|
<div className="col-span-2 pl-4 md:pl-6">Response</div>
|
||||||
<div className="px-4 md:px-6">Time</div>
|
<div className="px-4 md:px-6">Time</div>
|
||||||
</div>
|
</div>
|
||||||
{hiddenFieldResponses
|
{questionSummary.samples.map((response) => (
|
||||||
?.find((q) => q.question === question)
|
<div
|
||||||
?.responses.map((response) => {
|
key={response.value}
|
||||||
const displayIdentifier = getPersonIdentifier(response.person!);
|
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 md:text-base">
|
||||||
return (
|
<div className="pl-4 md:pl-6">
|
||||||
<div
|
{response.person ? (
|
||||||
key={response.id}
|
<Link
|
||||||
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 md:text-base">
|
className="ph-no-capture group flex items-center"
|
||||||
<div className="pl-4 md:pl-6">
|
href={`/environments/${environment.id}/people/${response.person.id}`}>
|
||||||
{response.person ? (
|
<div className="hidden md:flex">
|
||||||
<Link
|
<PersonAvatar personId={response.person.id} />
|
||||||
className="ph-no-capture group flex items-center"
|
</div>
|
||||||
href={`/environments/${environment.id}/people/${response.person.id}`}>
|
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
|
||||||
<div className="hidden md:flex">
|
{getPersonIdentifier(response.person)}
|
||||||
<PersonAvatar personId={response.person.id} />
|
</p>
|
||||||
</div>
|
</Link>
|
||||||
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
|
) : (
|
||||||
{displayIdentifier}
|
<div className="group flex items-center">
|
||||||
</p>
|
<div className="hidden md:flex">
|
||||||
</Link>
|
<PersonAvatar personId="anonymous" />
|
||||||
) : (
|
</div>
|
||||||
<div className="group flex items-center">
|
<p className="break-all text-slate-600 md:ml-2">Anonymous</p>
|
||||||
<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>
|
<div className="ph-no-capture col-span-2 whitespace-pre-wrap pl-6 font-semibold">
|
||||||
<div className="px-4 text-slate-500 md:px-6">
|
{response.value}
|
||||||
{timeSince(response.updatedAt.toISOString())}
|
</div>
|
||||||
</div>
|
<div className="px-4 text-slate-500 md:px-6">
|
||||||
</div>
|
{timeSince(new Date(response.updatedAt).toISOString())}
|
||||||
);
|
</div>
|
||||||
})}
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+21
-129
@@ -1,131 +1,34 @@
|
|||||||
import Headline from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/Headline";
|
import Headline from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/Headline";
|
||||||
import { questionTypes } from "@/app/lib/questions";
|
import { questionTypes } from "@/app/lib/questions";
|
||||||
import { InboxStackIcon } from "@heroicons/react/24/solid";
|
import { InboxIcon } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useMemo, useState } from "react";
|
|
||||||
|
|
||||||
import { getPersonIdentifier } from "@formbricks/lib/person/util";
|
import { getPersonIdentifier } from "@formbricks/lib/person/util";
|
||||||
import type { TSurveyQuestionSummary } from "@formbricks/types/surveys";
|
import { TSurveySummaryMultipleChoice } from "@formbricks/types/responses";
|
||||||
import {
|
|
||||||
TSurveyMultipleChoiceMultiQuestion,
|
|
||||||
TSurveyMultipleChoiceSingleQuestion,
|
|
||||||
TSurveyQuestionType,
|
|
||||||
} from "@formbricks/types/surveys";
|
|
||||||
import { PersonAvatar } from "@formbricks/ui/Avatars";
|
import { PersonAvatar } from "@formbricks/ui/Avatars";
|
||||||
import { ProgressBar } from "@formbricks/ui/ProgressBar";
|
import { ProgressBar } from "@formbricks/ui/ProgressBar";
|
||||||
|
|
||||||
interface MultipleChoiceSummaryProps {
|
interface MultipleChoiceSummaryProps {
|
||||||
questionSummary: TSurveyQuestionSummary<
|
questionSummary: TSurveySummaryMultipleChoice;
|
||||||
TSurveyMultipleChoiceMultiQuestion | TSurveyMultipleChoiceSingleQuestion
|
|
||||||
>;
|
|
||||||
environmentId: string;
|
environmentId: string;
|
||||||
surveyType: 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({
|
export default function MultipleChoiceSummary({
|
||||||
questionSummary,
|
questionSummary,
|
||||||
environmentId,
|
environmentId,
|
||||||
surveyType,
|
surveyType,
|
||||||
responsesPerPage,
|
|
||||||
}: MultipleChoiceSummaryProps) {
|
}: MultipleChoiceSummaryProps) {
|
||||||
const isSingleChoice = questionSummary.question.type === TSurveyQuestionType.MultipleChoiceSingle;
|
|
||||||
const [otherDisplayCount, setOtherDisplayCount] = useState(responsesPerPage);
|
|
||||||
const questionTypeInfo = questionTypes.find((type) => type.id === questionSummary.question.type);
|
const questionTypeInfo = questionTypes.find((type) => type.id === questionSummary.question.type);
|
||||||
|
|
||||||
const results: ChoiceResult[] = useMemo(() => {
|
// sort by count and transform to array
|
||||||
if (!("choices" in questionSummary.question)) return [];
|
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'
|
||||||
|
|
||||||
// build a dictionary of choices
|
// Sort by count
|
||||||
const resultsDict: { [key: string]: ChoiceResult } = {};
|
return b.count - a.count;
|
||||||
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 (
|
return (
|
||||||
<div className=" rounded-lg border border-slate-200 bg-slate-50 shadow-sm">
|
<div className=" rounded-lg border border-slate-200 bg-slate-50 shadow-sm">
|
||||||
@@ -138,8 +41,8 @@ export default function MultipleChoiceSummary({
|
|||||||
Multiple-Choice {questionTypeInfo ? questionTypeInfo.label : "Unknown Question Type"} Question
|
Multiple-Choice {questionTypeInfo ? questionTypeInfo.label : "Unknown Question Type"} Question
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center rounded-lg bg-slate-100 p-2">
|
<div className="flex items-center rounded-lg bg-slate-100 p-2">
|
||||||
<InboxStackIcon className="mr-2 h-4 w-4 " />
|
<InboxIcon className="mr-2 h-4 w-4 " />
|
||||||
{totalResponses} responses
|
{questionSummary.responseCount} responses
|
||||||
</div>
|
</div>
|
||||||
{!questionSummary.question.required && (
|
{!questionSummary.question.required && (
|
||||||
<div className="flex items-center rounded-lg bg-slate-100 p-2">Optional</div>
|
<div className="flex items-center rounded-lg bg-slate-100 p-2">Optional</div>
|
||||||
@@ -151,16 +54,16 @@ export default function MultipleChoiceSummary({
|
|||||||
</div>
|
</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 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: any, resultsIdx) => (
|
{results.map((result, resultsIdx) => (
|
||||||
<div key={result.label}>
|
<div key={result.value}>
|
||||||
<div className="text flex flex-col justify-between px-2 pb-2 sm:flex-row">
|
<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">
|
<div className="mr-8 flex w-full justify-between space-x-1 sm:justify-normal">
|
||||||
<p className="font-semibold text-slate-700">
|
<p className="font-semibold text-slate-700">
|
||||||
{results.length - resultsIdx} - {result.label}
|
{results.length - resultsIdx} - {result.value}
|
||||||
</p>
|
</p>
|
||||||
<div>
|
<div>
|
||||||
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
|
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
|
||||||
{Math.round(result.percentage * 100)}%
|
{Math.round(result.percentage)}%
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -168,16 +71,15 @@ export default function MultipleChoiceSummary({
|
|||||||
{result.count} {result.count === 1 ? "response" : "responses"}
|
{result.count} {result.count === 1 ? "response" : "responses"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<ProgressBar barColor="bg-brand" progress={result.percentage} />
|
<ProgressBar barColor="bg-brand" progress={result.percentage / 100} />
|
||||||
{result.otherValues.length > 0 && (
|
{result.others && result.others.length > 0 && (
|
||||||
<div className="mt-4 rounded-lg border border-slate-200">
|
<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="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 "Other" answers</div>
|
<div className="col-span-1 pl-6 ">Specified "Other" answers</div>
|
||||||
<div className="col-span-1 pl-6 ">{surveyType === "web" && "User"}</div>
|
<div className="col-span-1 pl-6 ">{surveyType === "web" && "User"}</div>
|
||||||
</div>
|
</div>
|
||||||
{result.otherValues
|
{result.others
|
||||||
.filter((otherValue) => otherValue !== "")
|
.filter((otherValue) => otherValue.value !== "")
|
||||||
.slice(0, otherDisplayCount)
|
|
||||||
.map((otherValue, idx) => (
|
.map((otherValue, idx) => (
|
||||||
<div key={idx}>
|
<div key={idx}>
|
||||||
{surveyType === "link" && (
|
{surveyType === "link" && (
|
||||||
@@ -187,7 +89,7 @@ export default function MultipleChoiceSummary({
|
|||||||
<span>{otherValue.value}</span>
|
<span>{otherValue.value}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{surveyType === "web" && (
|
{surveyType === "web" && otherValue.person && (
|
||||||
<Link
|
<Link
|
||||||
href={
|
href={
|
||||||
otherValue.person.id
|
otherValue.person.id
|
||||||
@@ -207,16 +109,6 @@ export default function MultipleChoiceSummary({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+16
-80
@@ -1,82 +1,17 @@
|
|||||||
import Headline from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/Headline";
|
import Headline from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/Headline";
|
||||||
import { questionTypes } from "@/app/lib/questions";
|
import { questionTypes } from "@/app/lib/questions";
|
||||||
import { InboxStackIcon } from "@heroicons/react/24/solid";
|
import { InboxIcon } from "lucide-react";
|
||||||
import { useMemo } from "react";
|
|
||||||
|
|
||||||
import type { TSurveyQuestionSummary } from "@formbricks/types/surveys";
|
import { TSurveySummaryNps } from "@formbricks/types/responses";
|
||||||
import { TSurveyNPSQuestion } from "@formbricks/types/surveys";
|
|
||||||
import { HalfCircle, ProgressBar } from "@formbricks/ui/ProgressBar";
|
import { HalfCircle, ProgressBar } from "@formbricks/ui/ProgressBar";
|
||||||
|
|
||||||
interface NPSSummaryProps {
|
interface NPSSummaryProps {
|
||||||
questionSummary: TSurveyQuestionSummary<TSurveyNPSQuestion>;
|
questionSummary: TSurveySummaryNps;
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
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 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 (
|
return (
|
||||||
<div className=" rounded-lg border border-slate-200 bg-slate-50 shadow-sm">
|
<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">
|
<div className="space-y-2 px-4 pb-5 pt-6 md:px-6">
|
||||||
@@ -88,8 +23,8 @@ export default function NPSSummary({ questionSummary }: NPSSummaryProps) {
|
|||||||
{questionTypeInfo ? questionTypeInfo.label : "Unknown Question Type"}
|
{questionTypeInfo ? questionTypeInfo.label : "Unknown Question Type"}
|
||||||
</div>
|
</div>
|
||||||
<div className=" flex items-center rounded-lg bg-slate-100 p-2">
|
<div className=" flex items-center rounded-lg bg-slate-100 p-2">
|
||||||
<InboxStackIcon className="mr-2 h-4 w-4 " />
|
<InboxIcon className="mr-2 h-4 w-4 " />
|
||||||
{result.total} responses
|
{questionSummary.responseCount} responses
|
||||||
</div>
|
</div>
|
||||||
{!questionSummary.question.required && (
|
{!questionSummary.question.required && (
|
||||||
<div className="flex items-center rounded-lg bg-slate-100 p-2">Optional</div>
|
<div className="flex items-center rounded-lg bg-slate-100 p-2">Optional</div>
|
||||||
@@ -104,40 +39,41 @@ export default function NPSSummary({ questionSummary }: NPSSummaryProps) {
|
|||||||
<p className="font-semibold capitalize text-slate-700">{group}</p>
|
<p className="font-semibold capitalize text-slate-700">{group}</p>
|
||||||
<div>
|
<div>
|
||||||
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
|
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
|
||||||
{Math.round(percentage(result[group], result.total) * 100)}%
|
{Math.round(questionSummary[group].percentage)}%
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="flex w-32 items-end justify-end text-slate-600">
|
<p className="flex w-32 items-end justify-end text-slate-600">
|
||||||
{result[group]} {result[group] === 1 ? "response" : "responses"}
|
{questionSummary[group].count} {questionSummary[group].count === 1 ? "response" : "responses"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<ProgressBar barColor="bg-brand" progress={percentage(result[group], result.total)} />
|
<ProgressBar barColor="bg-brand" progress={questionSummary[group].percentage / 100} />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{dismissed.count > 0 && (
|
{questionSummary.dismissed?.count > 0 && (
|
||||||
<div className="border-t bg-white px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
|
<div className="border-t bg-white px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
|
||||||
<div key={dismissed.label}>
|
<div key={"dismissed"}>
|
||||||
<div className="text flex justify-between px-2 pb-2">
|
<div className="text flex justify-between px-2 pb-2">
|
||||||
<div className="mr-8 flex space-x-1">
|
<div className="mr-8 flex space-x-1">
|
||||||
<p className="font-semibold text-slate-700">{dismissed.label}</p>
|
<p className="font-semibold text-slate-700">dismissed</p>
|
||||||
<div>
|
<div>
|
||||||
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
|
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
|
||||||
{Math.round(dismissed.percentage * 100)}%
|
{Math.round(questionSummary.dismissed.percentage)}%
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="flex w-32 items-end justify-end text-slate-600">
|
<p className="flex w-32 items-end justify-end text-slate-600">
|
||||||
{dismissed.count} {dismissed.count === 1 ? "response" : "responses"}
|
{questionSummary.dismissed.count}{" "}
|
||||||
|
{questionSummary.dismissed.count === 1 ? "response" : "responses"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<ProgressBar barColor="bg-slate-600" progress={dismissed.percentage} />
|
<ProgressBar barColor="bg-slate-600" progress={questionSummary.dismissed.percentage / 100} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex justify-center rounded-b-lg bg-white pb-4 pt-4">
|
<div className="flex justify-center rounded-b-lg bg-white pb-4 pt-4">
|
||||||
<HalfCircle value={result.score} />
|
<HalfCircle value={questionSummary.score} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+36
-56
@@ -1,28 +1,20 @@
|
|||||||
import Headline from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/Headline";
|
import Headline from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/Headline";
|
||||||
import { questionTypes } from "@/app/lib/questions";
|
import { questionTypes } from "@/app/lib/questions";
|
||||||
import { InboxStackIcon } from "@heroicons/react/24/solid";
|
import { InboxIcon } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
import { getPersonIdentifier } from "@formbricks/lib/person/util";
|
import { getPersonIdentifier } from "@formbricks/lib/person/util";
|
||||||
import { timeSince } from "@formbricks/lib/time";
|
import { timeSince } from "@formbricks/lib/time";
|
||||||
import type { TSurveyQuestionSummary } from "@formbricks/types/surveys";
|
import { TSurveySummaryOpenText } from "@formbricks/types/responses";
|
||||||
import { TSurveyOpenTextQuestion } from "@formbricks/types/surveys";
|
|
||||||
import { PersonAvatar } from "@formbricks/ui/Avatars";
|
import { PersonAvatar } from "@formbricks/ui/Avatars";
|
||||||
|
|
||||||
interface OpenTextSummaryProps {
|
interface OpenTextSummaryProps {
|
||||||
questionSummary: TSurveyQuestionSummary<TSurveyOpenTextQuestion>;
|
questionSummary: TSurveySummaryOpenText;
|
||||||
environmentId: string;
|
environmentId: string;
|
||||||
responsesPerPage: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function OpenTextSummary({
|
export default function OpenTextSummary({ questionSummary, environmentId }: OpenTextSummaryProps) {
|
||||||
questionSummary,
|
|
||||||
environmentId,
|
|
||||||
responsesPerPage,
|
|
||||||
}: OpenTextSummaryProps) {
|
|
||||||
const questionTypeInfo = questionTypes.find((type) => type.id === questionSummary.question.type);
|
const questionTypeInfo = questionTypes.find((type) => type.id === questionSummary.question.type);
|
||||||
const [displayCount, setDisplayCount] = useState(responsesPerPage);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-slate-200 bg-slate-50 shadow-sm">
|
<div className="rounded-lg border border-slate-200 bg-slate-50 shadow-sm">
|
||||||
@@ -34,8 +26,8 @@ export default function OpenTextSummary({
|
|||||||
{questionTypeInfo ? questionTypeInfo.label : "Unknown Question Type"} Question
|
{questionTypeInfo ? questionTypeInfo.label : "Unknown Question Type"} Question
|
||||||
</div>
|
</div>
|
||||||
<div className=" flex items-center rounded-lg bg-slate-100 p-2">
|
<div className=" flex items-center rounded-lg bg-slate-100 p-2">
|
||||||
<InboxStackIcon className="mr-2 h-4 w-4" />
|
<InboxIcon className="mr-2 h-4 w-4" />
|
||||||
{questionSummary.responses.length} Responses
|
{questionSummary.responseCount} Responses
|
||||||
</div>
|
</div>
|
||||||
{!questionSummary.question.required && (
|
{!questionSummary.question.required && (
|
||||||
<div className="flex items-center rounded-lg bg-slate-100 p-2">Optional</div>
|
<div className="flex items-center rounded-lg bg-slate-100 p-2">Optional</div>
|
||||||
@@ -48,51 +40,39 @@ export default function OpenTextSummary({
|
|||||||
<div className="col-span-2 pl-4 md:pl-6">Response</div>
|
<div className="col-span-2 pl-4 md:pl-6">Response</div>
|
||||||
<div className="px-4 md:px-6">Time</div>
|
<div className="px-4 md:px-6">Time</div>
|
||||||
</div>
|
</div>
|
||||||
{questionSummary.responses.slice(0, displayCount).map((response) => {
|
{questionSummary.samples.map((response) => (
|
||||||
const displayIdentifier = getPersonIdentifier(response.person!);
|
<div
|
||||||
return (
|
key={response.id}
|
||||||
<div
|
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 md:text-base">
|
||||||
key={response.id}
|
<div className="pl-4 md:pl-6">
|
||||||
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 md:text-base">
|
{response.person ? (
|
||||||
<div className="pl-4 md:pl-6">
|
<Link
|
||||||
{response.person ? (
|
className="ph-no-capture group flex items-center"
|
||||||
<Link
|
href={`/environments/${environmentId}/people/${response.person.id}`}>
|
||||||
className="ph-no-capture group flex items-center"
|
<div className="hidden md:flex">
|
||||||
href={`/environments/${environmentId}/people/${response.person.id}`}>
|
<PersonAvatar personId={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>
|
||||||
)}
|
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
|
||||||
</div>
|
{getPersonIdentifier(response.person)}
|
||||||
<div className="ph-no-capture col-span-2 whitespace-pre-wrap pl-6 font-semibold">
|
</p>
|
||||||
{response.value}
|
</Link>
|
||||||
</div>
|
) : (
|
||||||
<div className="px-4 text-slate-500 md:px-6">{timeSince(response.updatedAt.toISOString())}</div>
|
<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>
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+8
-63
@@ -1,75 +1,20 @@
|
|||||||
import Headline from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/Headline";
|
import Headline from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/Headline";
|
||||||
import { questionTypes } from "@/app/lib/questions";
|
import { questionTypes } from "@/app/lib/questions";
|
||||||
import { InboxStackIcon } from "@heroicons/react/24/solid";
|
import { InboxIcon } from "lucide-react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useMemo } from "react";
|
|
||||||
|
|
||||||
import type { TSurveyPictureSelectionQuestion, TSurveyQuestionSummary } from "@formbricks/types/surveys";
|
import { TSurveySummaryPictureSelection } from "@formbricks/types/responses";
|
||||||
import { ProgressBar } from "@formbricks/ui/ProgressBar";
|
import { ProgressBar } from "@formbricks/ui/ProgressBar";
|
||||||
|
|
||||||
interface PictureChoiceSummaryProps {
|
interface PictureChoiceSummaryProps {
|
||||||
questionSummary: TSurveyQuestionSummary<TSurveyPictureSelectionQuestion>;
|
questionSummary: TSurveySummaryPictureSelection;
|
||||||
}
|
|
||||||
|
|
||||||
interface ChoiceResult {
|
|
||||||
id: string;
|
|
||||||
imageUrl: string;
|
|
||||||
count: number;
|
|
||||||
percentage?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PictureChoiceSummary({ questionSummary }: PictureChoiceSummaryProps) {
|
export default function PictureChoiceSummary({ questionSummary }: PictureChoiceSummaryProps) {
|
||||||
const isMulti = questionSummary.question.allowMulti;
|
const isMulti = questionSummary.question.allowMulti;
|
||||||
const questionTypeInfo = questionTypes.find((type) => type.id === questionSummary.question.type);
|
const questionTypeInfo = questionTypes.find((type) => type.id === questionSummary.question.type);
|
||||||
|
|
||||||
const results: ChoiceResult[] = useMemo(() => {
|
const results = questionSummary.choices.sort((a, b) => b.count - a.count);
|
||||||
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 (
|
return (
|
||||||
<div className=" rounded-lg border border-slate-200 bg-slate-50 shadow-sm">
|
<div className=" rounded-lg border border-slate-200 bg-slate-50 shadow-sm">
|
||||||
@@ -82,8 +27,8 @@ export default function PictureChoiceSummary({ questionSummary }: PictureChoiceS
|
|||||||
{questionTypeInfo ? questionTypeInfo.label : "Unknown Question Type"} Question
|
{questionTypeInfo ? questionTypeInfo.label : "Unknown Question Type"} Question
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center rounded-lg bg-slate-100 p-2">
|
<div className="flex items-center rounded-lg bg-slate-100 p-2">
|
||||||
<InboxStackIcon className="mr-2 h-4 w-4 " />
|
<InboxIcon className="mr-2 h-4 w-4 " />
|
||||||
{totalResponses} responses
|
{questionSummary.responseCount} responses
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center rounded-lg bg-slate-100 p-2">
|
<div className="flex items-center rounded-lg bg-slate-100 p-2">
|
||||||
{isMulti ? "Multi" : "Single"} Select
|
{isMulti ? "Multi" : "Single"} Select
|
||||||
@@ -109,7 +54,7 @@ export default function PictureChoiceSummary({ questionSummary }: PictureChoiceS
|
|||||||
</div>
|
</div>
|
||||||
<div className="self-end">
|
<div className="self-end">
|
||||||
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
|
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
|
||||||
{Math.round((result.percentage || 0) * 100)}%
|
{Math.round(result.percentage)}%
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -117,7 +62,7 @@ export default function PictureChoiceSummary({ questionSummary }: PictureChoiceS
|
|||||||
{result.count} {result.count === 1 ? "response" : "responses"}
|
{result.count} {result.count === 1 ? "response" : "responses"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<ProgressBar barColor="bg-brand" progress={result.percentage || 0} />
|
<ProgressBar barColor="bg-brand" progress={result.percentage / 100 || 0} />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+19
-97
@@ -1,98 +1,19 @@
|
|||||||
import Headline from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/Headline";
|
import Headline from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/Headline";
|
||||||
import { questionTypes } from "@/app/lib/questions";
|
import { questionTypes } from "@/app/lib/questions";
|
||||||
import { InboxStackIcon } from "@heroicons/react/24/solid";
|
import { CircleSlash2, InboxIcon, SmileIcon, StarIcon } from "lucide-react";
|
||||||
import { CircleSlash2, SmileIcon, StarIcon } from "lucide-react";
|
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
|
||||||
import type { TSurveyQuestionSummary } from "@formbricks/types/surveys";
|
import { TSurveySummaryRating } from "@formbricks/types/responses";
|
||||||
import { TSurveyQuestionType } from "@formbricks/types/surveys";
|
|
||||||
import { TSurveyRatingQuestion } from "@formbricks/types/surveys";
|
|
||||||
import { ProgressBar } from "@formbricks/ui/ProgressBar";
|
import { ProgressBar } from "@formbricks/ui/ProgressBar";
|
||||||
import { RatingResponse } from "@formbricks/ui/RatingResponse";
|
import { RatingResponse } from "@formbricks/ui/RatingResponse";
|
||||||
|
|
||||||
interface RatingSummaryProps {
|
interface RatingSummaryProps {
|
||||||
questionSummary: TSurveyQuestionSummary<TSurveyRatingQuestion>;
|
questionSummary: TSurveySummaryRating;
|
||||||
}
|
|
||||||
|
|
||||||
interface ChoiceResult {
|
|
||||||
label: number | string;
|
|
||||||
count: number;
|
|
||||||
percentage: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RatingSummary({ questionSummary }: RatingSummaryProps) {
|
export default function RatingSummary({ questionSummary }: RatingSummaryProps) {
|
||||||
const questionTypeInfo = questionTypes.find((type) => type.id === questionSummary.question.type);
|
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 getIconBasedOnScale = useMemo(() => {
|
||||||
const scale = questionSummary.question.scale;
|
const scale = questionSummary.question.scale;
|
||||||
if (scale === "number") return <CircleSlash2 className="h-4 w-4" />;
|
if (scale === "number") return <CircleSlash2 className="h-4 w-4" />;
|
||||||
@@ -111,12 +32,12 @@ export default function RatingSummary({ questionSummary }: RatingSummaryProps) {
|
|||||||
{questionTypeInfo ? questionTypeInfo.label : "Unknown Question Type"} Question
|
{questionTypeInfo ? questionTypeInfo.label : "Unknown Question Type"} Question
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center rounded-lg bg-slate-100 p-2">
|
<div className="flex items-center rounded-lg bg-slate-100 p-2">
|
||||||
<InboxStackIcon className="mr-2 h-4 w-4 " />
|
<InboxIcon className="mr-2 h-4 w-4 " />
|
||||||
{totalResponses} responses
|
{questionSummary.responseCount} responses
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
|
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
|
||||||
{getIconBasedOnScale}
|
{getIconBasedOnScale}
|
||||||
<div>Overall: {averageRating}</div>
|
<div>Overall: {questionSummary.average.toFixed(2)}</div>
|
||||||
</div>
|
</div>
|
||||||
{!questionSummary.question.required && (
|
{!questionSummary.question.required && (
|
||||||
<div className="flex items-center rounded-lg bg-slate-100 p-2">Optional</div>
|
<div className="flex items-center rounded-lg bg-slate-100 p-2">Optional</div>
|
||||||
@@ -124,20 +45,20 @@ export default function RatingSummary({ questionSummary }: RatingSummaryProps) {
|
|||||||
</div>
|
</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 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: any) => (
|
{questionSummary.choices.map((result) => (
|
||||||
<div key={result.label}>
|
<div key={result.rating}>
|
||||||
<div className="text flex justify-between px-2 pb-2">
|
<div className="text flex justify-between px-2 pb-2">
|
||||||
<div className="mr-8 flex space-x-1">
|
<div className="mr-8 flex items-center space-x-1">
|
||||||
<div className="font-semibold text-slate-700">
|
<div className="font-semibold text-slate-700">
|
||||||
<RatingResponse
|
<RatingResponse
|
||||||
scale={questionSummary.question.scale}
|
scale={questionSummary.question.scale}
|
||||||
answer={result.label}
|
answer={result.rating}
|
||||||
range={questionSummary.question.range}
|
range={questionSummary.question.range}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
|
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
|
||||||
{Math.round(result.percentage * 100)}%
|
{Math.round(result.percentage)}%
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -145,27 +66,28 @@ export default function RatingSummary({ questionSummary }: RatingSummaryProps) {
|
|||||||
{result.count} {result.count === 1 ? "response" : "responses"}
|
{result.count} {result.count === 1 ? "response" : "responses"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<ProgressBar barColor="bg-brand" progress={result.percentage} />
|
<ProgressBar barColor="bg-brand" progress={result.percentage / 100} />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{dismissed.count > 0 && (
|
{questionSummary.dismissed && questionSummary.dismissed.count > 0 && (
|
||||||
<div className="rounded-b-lg border-t bg-white px-6 pb-6 pt-4">
|
<div className="rounded-b-lg border-t bg-white px-6 pb-6 pt-4">
|
||||||
<div key={dismissed.label}>
|
<div key="dismissed">
|
||||||
<div className="text flex justify-between px-2 pb-2">
|
<div className="text flex justify-between px-2 pb-2">
|
||||||
<div className="mr-8 flex space-x-1">
|
<div className="mr-8 flex space-x-1">
|
||||||
<p className="font-semibold text-slate-700">{dismissed.label}</p>
|
<p className="font-semibold text-slate-700">dismissed</p>
|
||||||
<div>
|
<div>
|
||||||
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
|
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
|
||||||
{Math.round(dismissed.percentage * 100)}%
|
{Math.round(questionSummary.dismissed.percentage)}%
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="flex w-32 items-end justify-end text-slate-600">
|
<p className="flex w-32 items-end justify-end text-slate-600">
|
||||||
{dismissed.count} {dismissed.count === 1 ? "response" : "responses"}
|
{questionSummary.dismissed.count}{" "}
|
||||||
|
{questionSummary.dismissed.count === 1 ? "response" : "responses"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<ProgressBar barColor="bg-slate-600" progress={dismissed.percentage} />
|
<ProgressBar barColor="bg-slate-600" progress={questionSummary.dismissed.percentage / 100} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+18
-6
@@ -1,10 +1,18 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { generateSingleUseIdAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/actions";
|
import { generateSingleUseIdAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/actions";
|
||||||
import { ArrowLeftIcon, CodeBracketIcon, EnvelopeIcon, LinkIcon } from "@heroicons/react/24/outline";
|
import {
|
||||||
import { DocumentDuplicateIcon } from "@heroicons/react/24/solid";
|
ArrowLeftIcon,
|
||||||
import { BellRing, BlocksIcon, Code2Icon, RefreshCcw } from "lucide-react";
|
BellRing,
|
||||||
|
BlocksIcon,
|
||||||
|
Code2Icon,
|
||||||
|
CopyIcon,
|
||||||
|
LinkIcon,
|
||||||
|
MailIcon,
|
||||||
|
RefreshCcw,
|
||||||
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
|
|
||||||
@@ -28,13 +36,14 @@ interface ShareEmbedSurveyProps {
|
|||||||
user: TUser;
|
user: TUser;
|
||||||
}
|
}
|
||||||
export default function ShareEmbedSurvey({ survey, open, setOpen, webAppUrl, user }: ShareEmbedSurveyProps) {
|
export default function ShareEmbedSurvey({ survey, open, setOpen, webAppUrl, user }: ShareEmbedSurveyProps) {
|
||||||
|
const router = useRouter();
|
||||||
const environmentId = survey.environmentId;
|
const environmentId = survey.environmentId;
|
||||||
const isSingleUseLinkSurvey = survey.singleUse?.enabled ?? false;
|
const isSingleUseLinkSurvey = survey.singleUse?.enabled ?? false;
|
||||||
const { email } = user;
|
const { email } = user;
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ id: "email", label: "Embed in an Email", icon: EnvelopeIcon },
|
{ id: "email", label: "Embed in an Email", icon: MailIcon },
|
||||||
{ id: "webpage", label: "Embed in a Web Page", icon: CodeBracketIcon },
|
{ id: "webpage", label: "Embed in a Web Page", icon: Code2Icon },
|
||||||
{ id: "link", label: `${isSingleUseLinkSurvey ? "Single Use Links" : "Share the Link"}`, icon: LinkIcon },
|
{ id: "link", label: `${isSingleUseLinkSurvey ? "Single Use Links" : "Share the Link"}`, icon: LinkIcon },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -73,6 +82,9 @@ export default function ShareEmbedSurvey({ survey, open, setOpen, webAppUrl, use
|
|||||||
setActiveId(tabs[0].id);
|
setActiveId(tabs[0].id);
|
||||||
setOpen(open);
|
setOpen(open);
|
||||||
setShowInitialPage(open); // Reset to initial page when modal opens
|
setShowInitialPage(open); // Reset to initial page when modal opens
|
||||||
|
|
||||||
|
// fetch latest responses
|
||||||
|
router.refresh();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleInitialPageButton = () => {
|
const handleInitialPageButton = () => {
|
||||||
@@ -109,7 +121,7 @@ export default function ShareEmbedSurvey({ survey, open, setOpen, webAppUrl, use
|
|||||||
navigator.clipboard.writeText(surveyUrl);
|
navigator.clipboard.writeText(surveyUrl);
|
||||||
toast.success("URL copied to clipboard!");
|
toast.success("URL copied to clipboard!");
|
||||||
}}
|
}}
|
||||||
EndIcon={DocumentDuplicateIcon}>
|
EndIcon={CopyIcon}>
|
||||||
Copy Link
|
Copy Link
|
||||||
</Button>
|
</Button>
|
||||||
{survey.singleUse?.enabled && (
|
{survey.singleUse?.enabled && (
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user