Compare commits

...

130 Commits

Author SHA1 Message Date
github-actions[bot]
8a0f7fde3d Release formbricks-js 1.0.4 (#689)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2023-08-14 12:29:23 +02:00
Matti Nannt
01523393db Convert all attributes and userIds to string in formbricks-js (#688)
* convert any attribute input to string in formbricks-js

* add changeset, increase patch version of formbricks-js
2023-08-14 12:26:40 +02:00
Johannes
534dd5050d Make Survey Summary Page and Several Other Pages Responsive
Make Survey Summary Page and Several Other Pages Responsive
2023-08-14 11:12:48 +02:00
Johannes
a3e1e0498d fix restart UI 2023-08-14 11:11:50 +02:00
Johannes
beadbfa4b9 attributes and people page responsiveness 2023-08-14 10:53:35 +02:00
Johannes
a3162150a6 survey list and editor mobile tweaks 2023-08-14 10:43:30 +02:00
Johannes
1c6a5b2685 Add Loader in Product Delete button and tweak Weekly Summary UI
Add Loader in Product Delete button and tweak Weekly Summary UI
2023-08-14 10:33:56 +02:00
Johannes
9c8141abb2 Merge branch 'main' of github.com:formbricks/formbricks into shubham/for-1124-responsiveness-create-a-mobile-friendly-version-of-all-data 2023-08-14 10:23:16 +02:00
Johannes
88c17546b7 add font weight bold to weekly summary 2023-08-14 10:20:53 +02:00
Johannes
ccfc85f4fa Merge branch 'main' of github.com:formbricks/formbricks into shubham/for-1122-tweak-add-loading-state-to-delete-button-in-delete-product 2023-08-14 10:15:29 +02:00
Johannes
d9839aba24 Merge branch 'main' of github.com:formbricks/formbricks into shubham/for-1122-tweak-add-loading-state-to-delete-button-in-delete-product 2023-08-14 10:14:16 +02:00
Matti Nannt
d83c530012 Remove responses limit for link surveys on free plan (#686) 2023-08-14 09:50:58 +02:00
ShubhamPalriwala
8716367ec1 ui: data comps of survey summary are now responsive 2023-08-14 13:09:17 +05:30
ShubhamPalriwala
dcffb8106e feat: loader in product delete button 2023-08-13 09:54:10 +05:30
github-actions[bot]
315467ef3f Version Packages (#682)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2023-08-12 12:43:50 +02:00
Matti Nannt
3dde021cd0 Prepare release of Formbricks v1.0.2 (#681) 2023-08-12 12:39:18 +02:00
Matti Nannt
ebbde2b531 Fix WEBAPP_URL cannot be set with prebuilt Docker image (#680)
* Fix WEBAPP_URL cannot be set with prebuilt Docker image

* Extend default docker-compose file and production script

* Update docs
2023-08-12 11:53:23 +02:00
Dhruwang Jariwala
47a8fd6b62 Rewrite Api Key Settings to React Server Components (#654)
* moved apikey settings to server component

* rename ZApiKeyData to ZApiKeyCreateInput

* Make smaller improvements

---------

Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2023-08-11 17:23:59 +02:00
Shubham Palriwala
c6686209be Move Look & Feel Settings to React Server Components (#672)
* feat: migrate look and feel to serverside component with loading screen

* fix: use existing product type instead of creating a custom type

* fix: make improvements as Matti suggested

* change attributes order in updateProduct function

* run pnpm format

---------

Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2023-08-11 17:04:31 +02:00
Shubham Palriwala
09436c78fc Validate for E-Mail Address on Verification Page (#666)
* validate: for email in the user verification modal

* feat: email auth is now a server page, uses server-side zod lib for email input validation, removes validator lib

* Add FormWrapper to Error Message

---------

Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2023-08-11 16:36:26 +02:00
Dhruwang Jariwala
98cdf941e6 Improve Preview in Survey Editor with Mobile & Desktop View (#573)
* made modal component responsive

* added tab switch

* added mobile preview mode for surveys

* did some refactors

* did some refactors

* added type defs

* ran pnpm format

* removed an unused comment

* fixed variable name typo

* fixed UI bugs and added mobile mockup to link surveys

* restored changes from fix long description PR

* fixed scroll to top issue and toggle hide bug

* fixed minor animation bug

* fixed placement issue

* re-embed restart button, make phone preview more responsive

---------

Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2023-08-11 10:55:49 +02:00
Johannes
52a09aa3ae Introduce Restart Survey Button on Preview of Survey
Introduce Restart Survey Button on Preview of Survey
2023-08-11 09:13:02 +02:00
Johannes
bf028a5f64 Merge branch 'main' of github.com:formbricks/formbricks into shubham/for-1120-add-restart-button-to-survey-preview-ui-tweak 2023-08-11 09:12:15 +02:00
Johannes
5c60694117 Merge branch 'main' of github.com:formbricks/formbricks into shubham/for-1120-add-restart-button-to-survey-preview-ui-tweak 2023-08-11 08:52:52 +02:00
Matti Nannt
179f92077b Apply prettier formatting (#678) 2023-08-10 17:25:40 +02:00
Matti Nannt
9cdf446f65 Remove lodash dependency from formbricks-js (#677)
* Remove lodash dependency from formbricks-js

* add array utils file
2023-08-10 16:22:10 +02:00
ShubhamPalriwala
f98d4f5c11 fix: make button exactly like mock 2023-08-10 17:21:44 +05:30
Matti Nannt
142c1bd35b Fix saved changes are not visible in Survey Editor (#674) 2023-08-10 13:14:17 +02:00
ShubhamPalriwala
5e3ec7e4f0 feat: Restart Survey when Previewing a Survey & truncate logic values 2023-08-10 15:09:17 +05:30
Johannes
3bbb4170e2 Set Response Limit to 50 when an In-App Survey is Created and Fix Doc Feedback survey
Set Response Limit to 50 when an In-App Survey is Created and Fix Doc Feedback survey
2023-08-09 18:14:34 +02:00
Johannes
dc085c41c0 Merge branch 'main' of github.com:formbricks/formbricks into shubham/for-1119-tweak-set-default-for-in-app-surveys-to-limit-to-50 2023-08-09 18:12:38 +02:00
Matti Nannt
33b3887b84 Use .env.docker in advanced docker-compose setup to simplify file structure (#671) 2023-08-09 17:57:35 +02:00
Johannes
6a8805de0b Merge branch 'main' of github.com:formbricks/formbricks into shubham/for-1119-tweak-set-default-for-in-app-surveys-to-limit-to-50 2023-08-09 17:52:29 +02:00
Johannes
10e149bb02 Fix formbricks-js labels not showing correctly with custom label styles, add dark mode to demo app
Fix formbricks-js labels not showing correctly with custom label styles, add dark mode to demo app
2023-08-09 17:51:20 +02:00
Johannes
9f944249fc update formatting 2023-08-09 17:50:40 +02:00
Johannes
b5765fed74 add dark mode to widget 2023-08-09 17:48:20 +02:00
Johannes
eee9b29723 Merge branch 'main' of github.com:formbricks/formbricks into feature/FOR-1081 2023-08-09 17:02:51 +02:00
Johannes
a3aae4ab95 fix: fixes survey link share modal and adds a mobile nav menu
fix: fixes survey link share modal and adds a mobile nav menu
2023-08-09 16:12:07 +02:00
Johannes
5b9db8f353 Merge branch 'main' of github.com:formbricks/formbricks into fix/safari-mobile 2023-08-09 16:08:52 +02:00
Matti Nannt
9b98ca4f64 Fix thank you screen is disabled after completing a survey (#670) 2023-08-09 16:05:15 +02:00
Johannes
6572d5395b Merge branch 'main' of github.com:formbricks/formbricks into fix/safari-mobile 2023-08-09 15:57:21 +02:00
Johannes
cd753f1a67 Merge branch 'main' of github.com:formbricks/formbricks into shubham/for-1119-tweak-set-default-for-in-app-surveys-to-limit-to-50 2023-08-09 15:37:08 +02:00
Johannes
6f0a26904f Fix back button in link surveys with logic jumps
Fix back button in link surveys with logic jumps
2023-08-09 15:35:32 +02:00
Johannes
e1c8a715d1 Add UI to create webhooks on integrations page
Add UI to create webhooks on integrations page
2023-08-09 15:31:14 +02:00
Johannes
96e54dbb46 double check migration 2023-08-09 15:25:55 +02:00
Johannes
b71fdf3205 Merge branch 'main' of github.com:formbricks/formbricks into shubham/for-1035-add-webhooks-ui-on-integrations-page 2023-08-09 15:22:11 +02:00
ShubhamPalriwala
5520edb2c5 feat: set response limit to 50 if the created survey is in-app 2023-08-09 18:43:50 +05:30
Johannes
d824da610d Fix: layout issue on peoples page
Fix: layout issue on peoples page
2023-08-09 14:40:35 +02:00
Dhruwang Jariwala
2bebc9598c Rewrite profile settings page to server component (#642)
* Chore: moved profile settings to server component

* ran pnpm format

* fisxed a build issue

* made requested changes

* made some refactors

---------

Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2023-08-09 13:13:58 +02:00
Dhruwang Jariwala
580e51dcea Fix Logic Jumps issues in in-product survey (#667) 2023-08-09 12:10:07 +02:00
Matthias Nannt
b570f3c79d fix pnpm lock file 2023-08-09 09:36:16 +02:00
Matthias Nannt
cf94c1a6d1 Make next & back button follow logic jumps 2023-08-09 09:27:12 +02:00
Johannes
71832c590f ui/ux tweaks 2023-08-08 22:12:40 +02:00
ShubhamPalriwala
15050525fd fix: make components out of survey and trigger checklists and minor ux factoring 2023-08-08 21:21:15 +05:30
ShubhamPalriwala
dcc198b151 cleanup: addWebhookModal component 2023-08-08 20:29:28 +05:30
ShubhamPalriwala
3793f29d0a fix: correct typo in htmlFor label 2023-08-08 20:06:21 +05:30
ShubhamPalriwala
b6da482e3f feat: webhooks now have a name across the UI 2023-08-08 20:01:48 +05:30
ShubhamPalriwala
cd1d9196fc feat: add name in webhook model db in prisme 2023-08-08 19:41:52 +05:30
ShubhamPalriwala
e3c09ebec3 fix: loader for add webhook, test nedpoint check on webhook creation, survey check, updated webhook logo 2023-08-08 19:22:01 +05:30
Dhruwang
2bda12d4fc Fix: layout issue on peoples page 2023-08-08 17:56:39 +05:30
Matti Nannt
b072d3b549 Update pnpm lock file (#664) 2023-08-08 13:38:22 +02:00
Johannes
758fc9af4d tweaks 2023-08-08 13:29:34 +02:00
ShubhamPalriwala
d4a4b4ec41 fix: dropdown replaced with checkbox list and loader is implemented 2023-08-08 15:55:48 +05:30
Johannes
5aa38a6e39 Delete Team, Transfer Ownership and Leave Team functionality
Delete Team, Transfer Ownership and Leave Team functionality
2023-08-08 10:51:44 +02:00
Johannes
0ebef13805 fix build error 2023-08-08 10:46:41 +02:00
Johannes
a81ceff09e update formatting 2023-08-08 10:33:26 +02:00
Piyush Gupta
7ebdf9939e fix: added loading states in CTA 2023-08-08 10:16:48 +05:30
ShubhamPalriwala
7631783e7d fix: all webhook icons now use lucid 2023-08-08 09:46:08 +05:30
ShubhamPalriwala
c92b2b00e0 fix: webhook table wrapping & add webhook modal text and dropdown id 2023-08-08 09:38:15 +05:30
ShubhamPalriwala
e68a7fe763 fix: add webhook logo and card component flexibility 2023-08-08 09:21:03 +05:30
Piyush Gupta
a3b46ee532 Merge branch 'main' of https://github.com/formbricks/formbricks into feature/delete-team 2023-08-08 00:59:37 +05:30
Matti Nannt
a1a66ef6be Fix close-on-date pipeline not executed properly (#662)
* Fix github action closeOnDate pipeline

* remove prebuild turbo build
2023-08-07 19:54:23 +02:00
Matti Nannt
6a1b8106b7 Add prebuild as a build dependency (#661) 2023-08-07 18:15:28 +02:00
Matti Nannt
8b1a074e2c Update npm packages to latest minor version (#660) 2023-08-07 17:46:58 +02:00
Matti Nannt
57733a75fc Fix build error (#659) 2023-08-07 17:13:50 +02:00
Matti Nannt
e5ef71ae87 Fix product service throwing validation error (#658) 2023-08-07 17:02:20 +02:00
Matti Nannt
89dae8f1d8 Simplify highlightBorderColor in product type (#657) 2023-08-07 16:50:24 +02:00
Anshuman Pandey
370041b0ae A highlight border can now be added to the in-product modal using the product settings (#610)
* feat: added logic for adding highlight border

* feat: adds highlight border color to js widget

* fix: fixes class issue

* fix: removes log

* fix: fixes db fields

* fix: fixes border color edit

* fix: fixes highlight border styles in demo app and preview

* fix migrations

* remove console.log

* fix build issues

---------

Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2023-08-07 16:14:55 +02:00
Johannes
34ff14d43b Fix: In app survey preview container size too high
Fix: In app survey preview container size too high
2023-08-07 16:00:25 +02:00
Pradumn Kumar
b6c0dbf5d3 Close survey after x responses now needs to be set to a higher number than the number of current responses (#606)
* fix: fixes close survey on x response issue

* feat: updates

* chore: don't update _count

* chore: optimizations

* fix: fixes issue with not being able to enter a lower value at all

* update toast message

* add response count to toast

* only count completed responses

---------

Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2023-08-07 15:22:37 +02:00
Johannes
488e2801f0 Make template library searchable
Make template library searchable
2023-08-07 14:21:43 +02:00
Johannes
ae7d0a4846 Add input field validation to rename product, team and name
Add input field validation to rename product, team and name
2023-08-07 14:17:59 +02:00
Dhruwang
7d3fa70fe2 Fix: broken preview 2023-08-07 17:09:29 +05:30
ShubhamPalriwala
bb4052690e feat: link integrations page to webhook 2023-08-07 16:29:54 +05:30
ShubhamPalriwala
e8a286bd4e feat: webhooks UI 2023-08-07 16:10:34 +05:30
Piyush Gupta
205593d8d3 added loading in transfer ownership CTA 2023-08-06 15:46:11 +05:30
Piyush Gupta
37afd004af refactor: used isOwner in delete team 2023-08-06 15:30:54 +05:30
Piyush Gupta
ad86c4dbf4 fix: independent queries -> Txn 2023-08-06 15:17:08 +05:30
Piyush Gupta
fb64eb50a2 Merge branch 'main' of https://github.com/formbricks/formbricks into feature/delete-team 2023-08-06 14:58:59 +05:30
Piyush Gupta
8d7eeb045b feat: added transfer ownership 2023-08-06 14:58:52 +05:30
Shubham Palriwala
fdb1aa2299 Rewrite Person Detail Page to Server Components (#609)
* feat: migration /[personId] page to server side

* feat: decouple components in person page

* fix: ZDisplaysWithSurveyName now extends the ZDisplay type

* feat: drop custom service and use existing service for survey and response

* run pnpm format

* shift data fetching to component level but still server side

* rename event to action

* move special person services to activity service

* remove activityFeedItem type in ActivityFeed

* simplify TResponseWithSurvey

---------

Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2023-08-06 09:53:37 +02:00
Shubham Khunt
df9ff011f7 fix:removed toast 2023-08-06 11:51:31 +05:30
Shubham Khunt
e85d95a4eb fix:removed toast 2023-08-06 11:51:01 +05:30
Johannes
5c9605f4af Feature : Toggle Multi-Select and Single-Select Question Types
Feature : Toggle Multi-Select and Single-Select Question Types
2023-08-05 16:23:39 +02:00
Johannes
de3d580614 Merge branch 'main' of github.com:formbricks/formbricks into toggle-multi-and-single-select 2023-08-05 15:29:39 +02:00
Piyush Gupta
ccb89548f0 feat: added leave team 2023-08-05 17:49:06 +05:30
Anshuman Pandey
ad42f4cc55 Dramatically improve load times when creating a new team(#614)
* fix: attempts to reduce time taken to create team

* fix: fixes long time taken in team creation

* fix: refactors prisma logic

* feat: added logic for adding demo data while signing up

* fix: adds comment

* fix: adds another logic for adding demo data

* fix: adds service for adding demo data

* fix: fixes

* fix: adds demo product creation logic in next auth options

* fix: fixes next auth options

* fix: fixes team creation logic

* refactor: clean up

* fix: moves the logic for adding demo product while creating team in bg

* fix: moves individual queries in a transaction

* refactor: service

* fix: moves api route to app-dir

* fix: fixes api calls

* fix: fixes cache

* fix: removes unused code
2023-08-05 13:59:06 +02:00
Dhruwang
51dda67992 fixed invalid storedResponseValue issue 2023-08-05 15:08:34 +05:30
Dhruwang
0598ad2eaa Feat:Toggle Multi-Select and Single-Select Question Types 2023-08-05 12:39:48 +05:30
Meet Patel
44e48e3c3f Hide the clear button for input type search 2023-08-05 10:56:50 +05:30
Piyush Gupta
1551baeca7 merged with main 2023-08-05 09:33:58 +05:30
Piyush Gupta
c3f26f7ab8 feat: added delete team functionality 2023-08-05 09:32:23 +05:30
Meet Patel
e9e3de2ce8 Added 'type=search' and 'name=search' attributes to SearchBox in TemplateContainer 2023-08-04 22:22:42 +05:30
Meet Patel
34c4e9bc1a lucide search icon 2023-08-04 22:10:39 +05:30
Johannes
c707896eb6 Fix Weekly: Remove N/A completion rate, exclude "completed" survey if has no submission in last 7 days
Fix Weekly: Remove N/A completion rate, exclude "completed" survey if has no submission in last 7 days
2023-08-04 04:19:40 -05:00
Johannes
ee545b7ade remove N/A CR, exclude completed if has no submission 2023-08-04 09:51:49 +02:00
Johannes
7bf0fa450a Add structured data to blog articles, tweaked the SEO score of existing ones
Add structured data to blog articles, tweaked the SEO score of existing ones
2023-08-04 02:19:13 -05:00
Johannes
1a8618692a more metadata for articles 2023-08-03 20:28:38 +02:00
Matti Nannt
e5f371476c Fix formatting of preview components (#646)
* Fix formatting of preview components

* update npm packages
2023-08-03 17:03:53 +02:00
Shubham Palriwala
dba3677633 fix: documentation on create webhook had invalid body key and headers were not being showed as required (#643) 2023-08-03 13:48:31 +02:00
Anshuman Pandey
369c9ed7b2 fix: fixes survey link share modal and moble nav menu 2023-08-03 17:13:51 +05:30
Piyush Gupta
0776138c1c Merge branch 'main' of https://github.com/formbricks/formbricks into feature/delete-team 2023-08-02 23:40:30 +05:30
Matthias Nannt
235c1afe28 Fix formbricks-js labels not showing correctly with custom label styles 2023-08-02 16:59:57 +02:00
Meet Patel
17b9d686bd category button text & gap 2023-08-02 20:04:18 +05:30
Meet Patel
c423e43aee refined search for who completed onboarding 2023-08-02 19:39:38 +05:30
Meet Patel
712431e842 search box improved & default category set 2023-08-01 23:32:00 +05:30
Meet Patel
86da5ff2f4 search improved & bg white & category disabled on search 2023-07-31 23:03:37 +05:30
Meet Patel
eed9b6635d two word case handled 2023-07-31 15:06:12 +05:30
Meet Patel
892a58c45e Merge branch 'main' into search-template-library 2023-07-30 23:00:33 +05:30
Meet Patel
4fb9851a6d searchbox component 2023-07-30 22:49:48 +05:30
Meet Patel
09c37d78a2 filter logic moved out side return 2023-07-29 11:21:56 +05:30
Meet Patel
6335565bf9 search works 2023-07-29 11:20:14 +05:30
Piyush Gupta
1e816eb6d9 feat: added delete card and modal 2023-07-23 01:01:51 +05:30
Shubham Khunt
8c31c71251 fix: removed error toast 2023-07-22 19:27:53 +05:30
Shubham Khunt
62a08f304b padding issue fixed in delete dialog 2023-07-22 19:25:15 +05:30
Shubham Khunt
35057322a4 padding issue fixed in alert dialog 2023-07-22 19:25:14 +05:30
Shubham Khunt
b1a93de8db fix: toast error message updated 2023-07-22 19:25:14 +05:30
Shubham Khunt
aca32655cd fix: edit profile page input validation added 2023-07-22 19:25:14 +05:30
Shubham Khunt
8b14559d5f fix: Edit product page input validation completed 2023-07-22 19:25:14 +05:30
Shubham Khunt
43a623a61e fix: edit team page input validation added 2023-07-22 19:25:14 +05:30
210 changed files with 6559 additions and 2864 deletions

View File

@@ -1,4 +1,4 @@
name: Cron - weeklySummary
name: Cron - closeOnDate
on:
# "Scheduled workflows run on the latest commit on the default or base branch."
@@ -10,14 +10,14 @@ jobs:
cron-weeklySummary:
env:
APP_URL: ${{ secrets.APP_URL }}
CRON_API_KEY: ${{ secrets.CRON_SECRET }}
CRON_SECRET: ${{ secrets.CRON_SECRET }}
runs-on: ubuntu-latest
steps:
- name: cURL request
if: ${{ env.APP_URL && env.CRON_SECRET }}
run: |
curl ${{ secrets.APP_URL }}/api/cron/close_surveys \
curl ${{ env.APP_URL }}/api/cron/close_surveys \
-X POST \
-H 'content-type: application/json' \
-H 'authorization: ${{ secrets.CRON_SECRET }}' \
-H 'x-api-key: ${{ env.CRON_SECRET }}' \
--fail

View File

@@ -1,53 +1,76 @@
import fbsetup from "../../public/fb-setup.png";
import formbricks from "@formbricks/js";
import Image from "next/image";
import { useEffect, useState } from "react";
import fbsetup from "../../public/fb-setup.png";
export default function AppPage({}) {
const [darkMode, setDarkMode] = useState(false);
useEffect(() => {
if (darkMode) {
document.body.classList.add("dark");
} else {
document.body.classList.remove("dark");
}
}, [darkMode]);
return (
<div className="px-12 py-6">
<div>
<h1 className="text-2xl font-bold">Formbricks In-product Survey Demo App</h1>
<p className="text-slate-700">
This app helps you test your in-app surveys. You can create an test user actions, create and update
user attributes, etc.
</p>
<div className="h-screen bg-white px-12 py-6 dark:bg-slate-800">
<div className="flex justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">
Formbricks In-product Survey Demo App
</h1>
<p className="text-slate-700 dark:text-slate-300">
This app helps you test your in-app surveys. You can create and test user actions, create and
update user attributes, etc.
</p>
</div>
<button
className="rounded-lg bg-slate-200 px-6 py-1 dark:bg-slate-700 dark:text-slate-100"
onClick={() => setDarkMode(!darkMode)}>
Toggle Dark Mode
</button>
</div>
<div className="my-4 grid grid-cols-1 gap-6 md:grid-cols-2">
<div>
<div className="rounded-lg border border-slate-300 bg-slate-100 p-6">
<h3 className="text-lg font-semibold">Setup .env</h3>
<p className="text-slate-700">
<div className="rounded-lg border border-slate-300 bg-slate-100 p-6 dark:border-slate-600 dark:bg-slate-900">
<h3 className="text-lg font-semibold text-slate-900 dark:text-white">Setup .env</h3>
<p className="text-slate-700 dark:text-slate-300">
Copy the environment ID of your Formbricks app to the env variable in demo/.env
</p>
<Image src={fbsetup} alt="fb setup" className="mt-4 rounded" priority />
</div>
<div className="mt-4 rounded-lg border border-slate-300 bg-slate-100 p-6">
<h3 className="text-lg font-semibold">Widget Logs</h3>
<p className="text-slate-700">
Look at the logs to understand how the widget works. <strong>Open your browser console</strong>{" "}
to see the logs.
<div className="mt-4 rounded-lg border border-slate-300 bg-slate-100 p-6 dark:border-slate-600 dark:bg-slate-900">
<h3 className="text-lg font-semibold text-slate-900 dark:text-white">Widget Logs</h3>
<p className="text-slate-700 dark:text-slate-300">
Look at the logs to understand how the widget works.{" "}
<strong className="dark:text-white">Open your browser console</strong> to see the logs.
</p>
{/* <div className="max-h-[40vh] overflow-y-auto py-4">
{/* <div className="max-h-[40vh] overflow-y-auto py-4">
<LogsContainer />
</div> */}
</div>
</div>
<div className="md:grid md:grid-cols-3">
<div className="col-span-3 rounded-lg border border-slate-300 bg-slate-100 p-6">
<h3 className="text-lg font-semibold">Reset person / pull data from Formbricks app</h3>
<p className="text-slate-700">
<div className="col-span-3 rounded-lg border border-slate-300 bg-slate-100 p-6 dark:border-gray-600 dark:bg-gray-800">
<h3 className="text-lg font-semibold dark:text-white">
Reset person / pull data from Formbricks app
</h3>
<p className="text-slate-700 dark:text-gray-300">
On formbricks.logout() a few things happen: <strong>New person is created</strong> and{" "}
<strong>surveys & no-code actions are pulled from Formbricks:</strong>.
</p>
<button
className="my-4 rounded-lg bg-slate-500 px-6 py-3 text-white hover:bg-slate-700"
className="my-4 rounded-lg bg-slate-500 px-6 py-3 text-white hover:bg-slate-700 dark:bg-gray-700 dark:hover:bg-gray-600"
onClick={() => {
formbricks.logout();
}}>
Logout
</button>
<p className="text-xs text-slate-700">
<p className="text-xs text-slate-700 dark:text-gray-300">
If you made a change in Formbricks app and it does not seem to work, hit &apos;Logout&apos; and
try again.
</p>
@@ -56,7 +79,7 @@ export default function AppPage({}) {
<div className="p-6">
<div>
<button
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700"
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-gray-700 dark:hover:bg-gray-600"
onClick={() => {
formbricks.track("Code Action");
}}>
@@ -64,7 +87,7 @@ export default function AppPage({}) {
</button>
</div>
<div>
<p className="text-xs text-slate-700">
<p className="text-xs text-slate-700 dark:text-gray-300">
This button sends a{" "}
<a href="https://formbricks.com/docs/actions/code" className="underline" target="_blank">
Code Action
@@ -75,18 +98,24 @@ export default function AppPage({}) {
</div>
<div className="p-6">
<div>
<button className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700">
<button className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-gray-700 dark:hover:bg-gray-600">
No-Code Action
</button>
</div>
<div>
<p className="text-xs text-slate-700">
<p className="text-xs text-slate-700 dark:text-gray-300">
This button sends a{" "}
<a href="https://formbricks.com/docs/actions/no-code" className="underline" target="_blank">
<a
href="https://formbricks.com/docs/actions/no-code"
className="underline dark:text-blue-500"
target="_blank">
No Code Action
</a>{" "}
as long as you created it beforehand in the Formbricks App.{" "}
<a href="https://formbricks.com/docs/actions/no-code" target="_blank" className="underline">
<a
href="https://formbricks.com/docs/actions/no-code"
target="_blank"
className="underline dark:text-blue-500">
Here are instructions on how to do it.
</a>
</p>
@@ -98,17 +127,17 @@ export default function AppPage({}) {
onClick={() => {
formbricks.setAttribute("Plan", "Free");
}}
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700">
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-gray-700 dark:hover:bg-gray-600">
Set Plan to &apos;Free&apos;
</button>
</div>
<div>
<p className="text-xs text-slate-700">
<p className="text-xs text-slate-700 dark:text-gray-300">
This button sets the{" "}
<a
href="https://formbricks.com/docs/attributes/custom-attributes"
target="_blank"
className="underline">
className="underline dark:text-blue-500">
attribute
</a>{" "}
&apos;Plan&apos; to &apos;Free&apos;. If the attribute does not exist, it creates it.
@@ -121,17 +150,17 @@ export default function AppPage({}) {
onClick={() => {
formbricks.setAttribute("Plan", "Paid");
}}
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700">
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-gray-700 dark:hover:bg-gray-600">
Set Plan to &apos;Paid&apos;
</button>
</div>
<div>
<p className="text-xs text-slate-700">
<p className="text-xs text-slate-700 dark:text-gray-300">
This button sets the{" "}
<a
href="https://formbricks.com/docs/attributes/custom-attributes"
target="_blank"
className="underline">
className="underline dark:text-blue-500">
attribute
</a>{" "}
&apos;Plan&apos; to &apos;Paid&apos;. If the attribute does not exist, it creates it.
@@ -144,17 +173,17 @@ export default function AppPage({}) {
onClick={() => {
formbricks.setEmail("test@web.com");
}}
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700">
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-gray-700 dark:hover:bg-gray-600">
Set Email
</button>
</div>
<div>
<p className="text-xs text-slate-700">
<p className="text-xs text-slate-700 dark:text-gray-300">
This button sets the{" "}
<a
href="https://formbricks.com/docs/attributes/identify-users"
target="_blank"
className="underline">
className="underline dark:text-blue-500">
user email
</a>{" "}
&apos;test@web.com&apos;
@@ -167,17 +196,17 @@ export default function AppPage({}) {
onClick={() => {
formbricks.setUserId("THIS-IS-A-VERY-LONG-USER-ID-FOR-TESTING");
}}
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700">
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-gray-700 dark:hover:bg-gray-600">
Set User ID
</button>
</div>
<div>
<p className="text-xs text-slate-700">
<p className="text-xs text-slate-700 dark:text-gray-300">
This button sets an external{" "}
<a
href="https://formbricks.com/docs/attributes/identify-users"
target="_blank"
className="underline">
className="underline dark:text-blue-500">
user ID
</a>{" "}
to &apos;THIS-IS-A-VERY-LONG-USER-ID-FOR-TESTING&apos;

View File

@@ -5,6 +5,7 @@ module.exports = {
"./pages/**/*.{js,ts,jsx,tsx}",
"./components/**/*.{js,ts,jsx,tsx}",
],
darkMode: "class",
theme: {
extend: {},
},

View File

@@ -1,8 +1,7 @@
import { Button, Popover, PopoverContent, PopoverTrigger } from "@formbricks/ui";
import { useRouter } from "next/router";
import { useState } from "react";
import { handleFeedbackSubmit, updateFeedback } from "../../lib/handleFeedbackSubmit";
import { Popover, PopoverTrigger, PopoverContent } from "@formbricks/ui";
import { Button } from "@formbricks/ui";
import { useRouter } from "next/router";
export const DocsFeedback: React.FC = () => {
const router = useRouter();
@@ -26,7 +25,7 @@ export const DocsFeedback: React.FC = () => {
Is everything on this page clear?
<Popover open={isOpen} onOpenChange={setIsOpen}>
<div className="mt-2 inline-flex space-x-3 md:ml-4 md:mt-0">
{["Yes 👍", " No 👎"].map((option) => (
{["Yes 👍", "No 👎"].map((option) => (
<PopoverTrigger
key={option}
className="rounded border border-slate-200 bg-slate-50 px-4 py-2 text-slate-900 hover:bg-slate-100 hover:text-slate-600 focus:outline-none focus:ring-2 focus:ring-neutral-900 focus:ring-offset-1 dark:border-slate-700 dark:bg-slate-700 dark:text-slate-300 dark:hover:bg-slate-600 dark:hover:text-slate-300"

View File

@@ -10,6 +10,7 @@ interface APICallProps {
label: string;
type: string;
description: string;
required?: boolean;
}[];
bodies: {
label: string;
@@ -69,7 +70,13 @@ export function APILayout({ method, url, description, headers, bodies, responses
<p className="not-prose -mb-1 pt-2 font-bold">Headers</p>
<div>
{headers.map((q) => (
<Parameter key={q.label} label={q.label} type={q.type} description={q.description} />
<Parameter
key={q.label}
label={q.label}
type={q.type}
description={q.description}
required={q.required}
/>
))}
</div>
</div>

View File

@@ -26,6 +26,10 @@ interface Props {
meta: {
title: string;
description: string;
publishedTime: string;
authors: string[];
section: string;
tags: string[];
};
children: JSX.Element;
}
@@ -34,7 +38,14 @@ export default function LayoutMdx({ meta, children }: Props) {
useExternalLinks(".prose a");
return (
<div className="flex h-screen flex-col justify-between">
<MetaInformation title={meta.title} description={meta.description} />
<MetaInformation
title={meta.title}
description={meta.description}
publishedTime={meta.publishedTime}
authors={meta.authors}
section={meta.section}
tags={meta.tags}
/>
<Header />
<main className="min-w-0 max-w-2xl flex-auto px-4 lg:max-w-none lg:pl-8 lg:pr-0 xl:px-16">
<article className="mx-auto my-16 max-w-3xl px-2">

View File

@@ -3,10 +3,21 @@ import Head from "next/head";
interface Props {
title: string;
description: string;
publishedTime?: string;
authors?: string[];
section?: string;
tags?: string[];
}
export default function MetaInformation({ title, description }: Props) {
const pageTitle = `${title} | Open-Source Survey Software`;
export default function MetaInformation({
title,
description,
publishedTime,
authors,
section,
tags,
}: Props) {
const pageTitle = `${title} | Open-Source Experience Management, Privacy-first`;
return (
<Head>
<title>{pageTitle}</title>
@@ -14,13 +25,22 @@ export default function MetaInformation({ title, description }: Props) {
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:image" content={`https://${process.env.VERCEL_URL}/social-image.png`} />
<meta property="og:image:alt" content="Formbricks - Open Source Form and Survey Infrastructure" />
<meta property="og:image:alt" content="Formbricks - Open Source Experience Management, Privacy-first" />
<meta property="og:image:type" content="image/png" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:locale" content="en_US" />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="Open Source Forms and Surveys by Formbricks" />
<meta property="og:site_name" content="Open Source Experience Management, Privacy-first" />
<meta property="article:publisher" content="Formbricks" />
{publishedTime && <meta property="article:published_time" content={publishedTime} />}
{authors && <meta property="article:author" content={authors.join(", ")} />}
{section && <meta property="article:section" content={section} />}
{tags && <meta property="article:tag" content={tags.join(", ")} />}
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:site" content="@formbricks" />
<meta name="twitter:creator" content="@formbricks" />
<meta name="theme-color" content="#00C4B8" />
</Head>
);
}

View File

@@ -91,12 +91,16 @@ const nextConfig = {
destination: "/docs/actions/code",
permanent: true,
},
{
source: "/pmf",
destination: "/",
permanent: true,
},
{
source: "/blog/v1-and-how-we-got-here",
destination: "/blog/experience-management-open-source",
permanent: true,
},
];
},
};

View File

@@ -110,7 +110,7 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
"Typebot gives you powerful blocks to create unique chat experiences. Embed them anywhere on your apps and start collecting results like magic.",
href: "https://typebot.io",
},
{
{
name: "Twenty",
description:
"A modern CRM offering the flexibility of open-source, advanced features and sleek design.",

View File

@@ -9,35 +9,39 @@ import SurveyJS from "./surveyjs-free-opensource-form-survey-tool-software-to-ma
import AuthorBox from "@/components/shared/AuthorBox";
export const meta = {
title: "Best Open-source Form & Survey Tools (still maintained in 2023)",
title: "5 Open Source Survey and Form Tools maintained in 2023",
description:
"Most open-source projects get abandoned after a while. But these 5 open-source form and survey tools are still alive and kicking in 2023.",
"Most open source projects get abandoned after a while. But these 5 open source survey tools are still alive and kicking in 2023.",
date: "2023-04-12",
publishedTime: "2023-04-12T12:00:00",
authors: ["Johannes"],
section: "Open Source Surveys",
tags: ["Open Source Surveys", "Formbricks", "Typeform", "SurveyJS", "Typebot", "OpnForm", "LimeSurvey"],
};
<AuthorBox name="Johannes" title="Co-Founder" date="April 7th, 2023" duration="4" />
_Most open-source projects get abandoned after a while. But these 5 open-source form and survey tools are still alive and kicking in 2023._
_Most open source projects get abandoned after a while. But these 5 open source survey tools are still alive and kicking in 2023._
<Image
src={HeaderImage}
alt="Free and self-hostable: Find the 5 best (and maintained) open-source survey tools 2023."
alt="Open source survey tool self-hostable: Find the 5 best (and maintained) open source survey tool 2023."
className="rounded-lg"
/>
Looking for the perfect open-source form and survey tool to help you gather valuable insights and improve your business? Look no further!
Looking for the perfect open source survey tool to help you gather valuable insights and improve your business? Look no further!
We've compiled a list of the top 5 open-source form and survey tools that are still maintained in 2023. In-product surveys, conversational bots, AI-generated surveys: These tools offer various features that cater to different needs.
We've compiled a list of the top 5 open source form and survey tools that are still maintained in 2023. In app surveys, conversational bots, AI-generated surveys: These open source tools offer various features that cater to different needs.
## 1. Formbricks - In-product micro-surveys
## 1. Formbricks - In app micro surveys
<Image
src={Formbricks}
alt="Formbricks is a free and open-source survey software for in-product micro-surveys. Ask any segment at any point in time."
alt="Formbricks is a free and open source survey software for in app micro surveys. Ask any user segment at any point in the user journey."
className="rounded-lg"
/>
Formbricks is a powerful open-source form and survey solution designed to help you get better experience data for your business. This tool allows you to survey specific customer segments at any point in the user journey, providing you with invaluable insights into what your customers think and feel.
Formbricks is a powerful open source survey tool designed to help you get better experience data for your business. This tool allows you to survey specific customer segments at any point in the user journey, providing you with invaluable insights into what your customers think and feel about your product.
- 👍 **Pre-segment users:** Don't ask everyone, all the time. Granularly segment your user base to get deep insights
- 👍 **Event-based surveys:** Trigger surveys based on user behavior, such as page views, clicks, and more
@@ -45,11 +49,15 @@ Formbricks is a powerful open-source form and survey solution designed to help y
- 👍 **Easy self-hosting:** Docker makes it possible to self-host Formbricks in minutes.
- ⚠️ **It's early for Formbricks.** Product developes rapidly but might encounter a bug here and there.
[Try it out](https://app.formbricks.com), [read more](https://formbricks.com) or dive into the [code base.](https://formbricks.com/github)
[Try it out](https://app.formbricks.com), [read more](https://formbricks.com) or dive into the [code base](https://formbricks.com/github) or comprehensive [docs](https://formbricks.com/docs).
## 2. SurveyJS - Build-it-yourself libraries
## 2. SurveyJS - Build-it-yourself library
<Image src={SurveyJS} alt="SurveyJS is a comprehensive" className="rounded-lg" />
<Image
src={SurveyJS}
alt="SurveyJS is a comprehensive JS library to build your own form or survey application."
className="rounded-lg"
/>
SurveyJS is a collection of JavaScript Librarys to build forms. Building your own form management system has never been easier than with SurveyJS. It packs:
@@ -61,9 +69,13 @@ SurveyJS is a collection of JavaScript Librarys to build forms. Building your ow
[Dive into the code on GitHub](https://github.com/surveyjs)
## 3. Typebot - Truly conversational Forms
## 3. Typebot - Truly conversational forms
<Image src={Typebot} alt="SurveyJS is a comprehensive" className="rounded-lg" />
<Image
src={Typebot}
alt="Open source survey and form builder SurveyJS lets you build surveys fast"
className="rounded-lg"
/>
Coming in at number three on our list is Typebot, that makes it really easy to create conversational forms and surveys. Typebot helps you engage with your audience in a more interactive way, leading to higher response rates and better data. It comes with:
@@ -77,9 +89,13 @@ Coming in at number three on our list is Typebot, that makes it really easy to c
## 4. OpnForm - Straight-forward survey builder
<Image src={OpnForm} alt="SurveyJS is a comprehensive" className="rounded-lg" />
<Image
src={OpnForm}
alt="OpnForm is an open source form builder for experience management"
className="rounded-lg"
/>
OpnForms is a flexible and powerful open-source form and survey tool designed to make data collection easy and efficient. OpnForm packs lots of features, especially for a Beta:
OpnForms is a flexible and powerful open source form and survey tool designed to make data collection easy and efficient. OpnForm packs lots of features, especially for a Beta:
- 👍 **Multiple Question Types:** Choose from a wide variety of question types to create highly customizable forms and surveys.
- 👍 **Conditional Logic:** Show or hide questions based on previous responses to create personalized surveys.
@@ -90,7 +106,11 @@ OpnForms is a flexible and powerful open-source form and survey tool designed to
## 5. LimeSurvey - Old (but gold?)
<Image src={LimeSurvey} alt="SurveyJS is a comprehensive" className="rounded-lg" />
<Image
src={LimeSurvey}
alt="LimeSurvey is open source survey builder to manage experiences with forms"
className="rounded-lg"
/>
LimeSurvey has been around for at least a decade. It's a powerful survey tool made for more classical, scientific surveying. It packs:
@@ -104,9 +124,9 @@ LimeSurvey has been around for at least a decade. It's a powerful survey tool ma
## Summary ☟
In this article, we've rounded up the top 5 open-source form and survey tools that are still rocking it in 2023. Perfect for devs who are always on the lookout for the latest and greatest!
In this article, we've rounded up the top 5 open source form and survey tools that are still rocking it in 2023. Perfect for devs who are always on the lookout for the latest and greatest!
1. Formbricks: A game-changer for in-product micro-surveys, letting you target specific customer segments at any point in their journey. It's still early days, but this bad boy is worth keeping an eye on.
1. Formbricks: A game-changer for in app micro surveys, letting you target specific customer segments at any point in their journey. It's still early days, but this bad boy is worth keeping an eye on.
2. SurveyJS: A must-have for DIY enthusiasts, this collection of JavaScript libraries makes building your own form management system a breeze. Just remember, the starting price is $499/year.

View File

@@ -16,6 +16,10 @@ export const meta = {
description:
"A lot has happened since Matti and I had a chat about open-source surveys in May last year. The release of Formbricks v1.0 is a perfect opportunity to look back on how it all started.",
date: "2023-07-14",
publishedTime: "2023-07-14T08:10:33",
authors: ["Johannes"],
section: "Open Source Experience Management",
tags: ["Open Source", "Experience Management", "Formbricks"],
};
<AuthorBox name="Johannes" title="Co-Founder" date="April 7th, 2023" duration="4" />

View File

@@ -11,7 +11,11 @@ export const meta = {
title: "Our GitHub Accelerator Experience 👀",
description:
"What we learned during the first GitHub Open-Source Accelerator Programm - our experience and if we would do it again.",
date: "2023-04-13",
date: "2023-06-06",
publishedTime: "2023-06-06T05:12:25",
authors: ["Johannes"],
section: "GitHub Accelerator",
tags: ["GitHub Accelerator", "Open-Source", "Startup"],
};
<AuthorBox name="Johannes" title="Co-Founder" date="April 7th, 2023" duration="4" />
@@ -40,7 +44,7 @@ January and February came and went. On the 22nd of March, we received an email f
Needless to say, we were thrilled! We were selected from over 1000 open-source projects, alongside renowned and popular projects like [Nuxt](https://github.com/nuxt/nuxt), [TRPC](https://github.com/trpc/trpc), and [Responsively App](https://github.com/responsively-org/responsively-app). Here is a summary of what we got:
### What we got on paper
### What the GitHub Accelerator offers on paper
✅ Ten sessions with **well-known** figures from the open-source community (Wednesdays)
@@ -108,7 +112,7 @@ Ericas talk was really impressive and so is she: Founder of Bitnami, COO of GitH
**GitHub Demo Day:** All teams presented what they achieved during the 10 week programm. It was great fun to present Formbricks, [you can watch it on Youtube.](https://www.youtube.com/live/Gj6Bez2182k?feature=share&t=1448)
## Would we do it again? And should you?
## Would we do the GitHub Accelerator again? And should you?
Yes, absolutely. The sessions were excellent, we met a handful of inspiring builders, and the 20k USD was a helpful financial boost. The application process might evolve, but since all of your code is open-source anyway, you might as well throw your hat in the ring.

View File

@@ -9,6 +9,10 @@ export const meta = {
description:
"We're getting ready to take our open-source experience management platform to new heights, thanks to being part of the first-ever GitHub Accelerator program!",
date: "2023-04-13",
publishedTime: "2023-04-13T05:12:25",
authors: ["Johannes"],
section: "GitHub Accelerator",
tags: ["GitHub Accelerator", "Open-Source"],
};
<AuthorBox name="Johannes" title="Co-Founder" date="April 7th, 2023" duration="4" />

View File

@@ -10,6 +10,10 @@ export const meta = {
title: "Open source forms will save the world.",
description: "What motivates us to build open source tech in such a crowded space?",
date: "2022-08-26",
publishedTime: "2022-08-26T12:12:25",
authors: ["Johannes"],
section: "Open Source Survey Tool",
tags: ["Open Source", "Survey Tool", "Forms"],
};
<AuthorBox name="Johannes" title="Co-Founder" date="April 7th, 2023" duration="4" />

View File

@@ -16,6 +16,10 @@ export const meta = {
description:
"We kicked it off with a Typeform open-source alternative. As we build and learn, our focus is shifting. Read why:",
date: "2023-03-24",
publishedTime: "2023-03-23T08:20:15",
authors: ["Johannes"],
section: "Open Source Experience Management",
tags: ["Open Source", "Experience Management", "Typeform", "Qualtrics"],
};
<AuthorBox name="Johannes" title="Co-Founder" date="April 7th, 2023" duration="4" />

View File

@@ -8,6 +8,10 @@ export const meta = {
title: "snoopForms → Formbricks 🎉",
description: "A new name, a new look, key learnings and what's next.",
date: "2022-11-07",
publishedTime: "2023-07-11T12:00:06",
authors: ["Johannes"],
section: "Formbricks",
tags: ["Formbricks", "snoopForms", "Open Source"],
};
<AuthorBox name="Johannes" title="Co-Founder" date="April 7th, 2023" duration="4" />

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

View File

@@ -13,6 +13,10 @@ export const meta = {
description:
"Here is why a no-code interface is cheatcode for OSS and why particularly large corporations and governments are to benefit the most",
date: "2022-06-03",
publishedTime: "2022-06-03T06:04:08",
authors: ["Johannes"],
section: "Open-Source",
tags: ["Open-Source", "No-Code", "Enterprise", "Government"],
};
<AuthorBox name="Johannes" title="Co-Founder" date="April 7th, 2023" duration="4" />

View File

@@ -20,7 +20,7 @@ Please note that regardless of the method you choose, Formbricks is designed to
---
## (Production: Ubuntu) Running the Shell Script
## (Most users: One-click Ubuntu setup) Running the Shell Script
This is the quickest way to get Formbricks up and running on an Ubuntu server. The shell script will automatically install all the required dependencies and configure your server, making the process a breeze.
@@ -50,7 +50,7 @@ The script will prompt you for the following information:
That's it! After running the command and providing the required information, visit the domain name you entered, and you should see the Formbricks home wizard!
## (Most users: Local Setup) Running the pre-built Docker Image
## (Manual Deployment) Running the pre-built Docker Image
### Requirements
@@ -133,15 +133,9 @@ Ensure `docker` & `docker compose` are installed on your server/system. Both are
git clone https://github.com/formbricks/formbricks.git && cd formbricks
```
Create a `.env` file based on `.env.docker` and change all fields according to your setup. This file comes with a basic setup and Formbricks works without making any changes to the file. To enable email sending functionality you need to configure the SMTP settings in the `.env` file. If you configured your email credentials, you can also comment the following lines to enable email verification (`# NEXT_PUBLIC_EMAIL_VERIFICATION_DISABLED=1`) and password reset (`# NEXT_PUBLIC_PASSWORD_RESET_DISABLED=1`)
2. Modify the `.env.docker` file as required by your setup. This file comes with a basic setup and Formbricks works without making any changes to the file. To enable email sending functionality you need to configure the SMTP settings. If you configured your email credentials, you can also comment the following lines to enable email verification (`# NEXT_PUBLIC_EMAIL_VERIFICATION_DISABLED=1`) and password reset (`# NEXT_PUBLIC_PASSWORD_RESET_DISABLED=1`)
2. Copy the `.env.docker` file to `.env` and edit it with an editor of your choice if needed.
```bash
cp .env.docker .env
```
Note: The environment variables are used at build time. When you change environment variables later, you need to rebuild the image with `docker compose build` for the changes to take effect.
Note: All environment variables starting with `NEXT_PUBLIC_` are used at build time. When you change environment variables later, you need to rebuild the image with `docker compose build` for the changes to take effect.
3. Finally start the docker compose process to build and spin up the Formbricks container as well as the PostgreSQL database.
@@ -158,12 +152,12 @@ Certainly, here is the reformatted version for Formbricks environment variables.
These variables must also be provided at runtime.
| Variable | Description | Required | Default |
| ---------------------- | --------------------------------------------------------------------------------------- | -------- | ----------------------------------------------------------------------- |
| NEXT_PUBLIC_WEBAPP_URL | Base URL of the site. | required | `http://localhost:3000` |
| DATABASE_URL | Database URL with credentials. | required | `postgresql://postgres:postgres@postgres:5432/formbricks?schema=public` |
| NEXTAUTH_SECRET | Secret for NextAuth, used for session signing and 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` |
| Variable | Description | Required | Default |
| --------------- | --------------------------------------------------------------------------------------- | -------- | ----------------------------------------------------------------------- |
| WEBAPP_URL | Base URL of the site. | required | `http://localhost:3000` |
| DATABASE_URL | Database URL with credentials. | required | `postgresql://postgres:postgres@postgres:5432/formbricks?schema=public` |
| NEXTAUTH_SECRET | Secret for NextAuth, used for session signing and 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` |
### Build-time Variables

View File

@@ -41,7 +41,7 @@ export const meta = {
]}
example={`{
"url": "https://mysystem.com/myendpoint",
"trigger": "responseFinished"
"triggers": ["responseFinished"]
}`}
responses={[
{

View File

@@ -1,5 +1,20 @@
# @formbricks/web
## 1.0.3
### Patch Changes
- Updated dependencies [01523393]
- @formbricks/js@1.0.4
## 1.0.2
### Patch Changes
- 3dde021c: Release version 1.0.2
- Updated dependencies [3dde021c]
- @formbricks/js@1.0.3
## 0.1.2
### Patch Changes

View File

@@ -10,7 +10,7 @@ COPY .env.docker /app/apps/web/.env
RUN pnpm install
# Build the project
RUN pnpm prebuild --filter=web...
RUN pnpm post-install --filter=web...
RUN pnpm turbo run build --filter=web...
FROM node:18-alpine AS runner

View File

@@ -5,7 +5,7 @@ import { TagIcon } from "@heroicons/react/24/solid";
export default function AttributeClassDataRow({ attributeClass }) {
return (
<div className="m-2 grid h-16 grid-cols-5 content-center rounded-lg hover:bg-slate-100">
<div className="col-span-3 flex items-center pl-6 text-sm">
<div className="sm:col-span-3 col-span-5 flex items-center pl-6 text-sm">
<div className="flex items-center">
<div className="h-10 w-10 flex-shrink-0">
<TagIcon className="h-8 w-8 flex-shrink-0 text-slate-500" />
@@ -22,10 +22,10 @@ export default function AttributeClassDataRow({ attributeClass }) {
</div>
</div>
<div className="my-auto whitespace-nowrap text-center text-sm text-slate-500">
<div className="my-auto whitespace-nowrap text-center text-sm text-slate-500 hidden md:block">
<div className="text-slate-900">{timeSinceConditionally(attributeClass.createdAt.toString())}</div>
</div>
<div className="my-auto whitespace-nowrap text-center text-sm text-slate-500">
<div className="my-auto whitespace-nowrap text-center text-sm text-slate-500 hidden md:block">
<div className="text-slate-900">{timeSinceConditionally(attributeClass.updatedAt.toString())}</div>
</div>
</div>

View File

@@ -3,8 +3,8 @@ export default function AttributeTableHeading() {
<>
<div className="grid h-12 grid-cols-5 content-center rounded-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
<div className="col-span-3 pl-6 ">Name</div>
<div className="text-center">Created</div>
<div className="text-center">Last Updated</div>
<div className="text-center hidden sm:block">Created</div>
<div className="text-center hidden sm:block">Last Updated</div>
</div>
</>
);

View File

@@ -24,25 +24,29 @@ import {
changeEnvironmentByTeam,
} from "@/lib/environments/changeEnvironments";
import { useEnvironment } from "@/lib/environments/environments";
import { formbricksLogout } from "@/lib/formbricks";
import { useMemberships } from "@/lib/memberships";
import { useTeam } from "@/lib/teams/teams";
import { capitalizeFirstLetter, truncate } from "@/lib/utils";
import formbricks from "@formbricks/js";
import { cn } from "@formbricks/lib/cn";
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
import {
CustomersIcon,
DashboardIcon,
ErrorComponent,
FilterIcon,
FormIcon,
ProfileAvatar,
FormIcon, Popover, PopoverContent, PopoverTrigger, ProfileAvatar,
SettingsIcon,
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
TooltipTrigger
} from "@formbricks/ui";
import {
AdjustmentsVerticalIcon,
ArrowRightOnRectangleIcon,
ChatBubbleBottomCenterTextIcon,
ChevronDownIcon,
CodeBracketIcon,
CreditCardIcon,
@@ -52,9 +56,9 @@ import {
PlusIcon,
UserCircleIcon,
UsersIcon,
ChatBubbleBottomCenterTextIcon,
} from "@heroicons/react/24/solid";
import clsx from "clsx";
import { MenuIcon } from "lucide-react";
import type { Session } from "next-auth";
import { signOut } from "next-auth/react";
import Image from "next/image";
@@ -62,9 +66,6 @@ import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import AddProductModal from "./AddProductModal";
import { formbricksLogout } from "@/lib/formbricks";
import formbricks from "@formbricks/js";
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
interface EnvironmentsNavbarProps {
environmentId: string;
@@ -86,6 +87,8 @@ export default function EnvironmentsNavbar({ environmentId, session }: Environme
const [showAddProductModal, setShowAddProductModal] = useState(false);
const [showCreateTeamModal, setShowCreateTeamModal] = useState(false);
const [mobileNavMenuOpen, setMobileNavMenuOpen] = useState(false);
useEffect(() => {
if (environment && environment.widgetSetupCompleted) {
setWidgetSetupCompleted(true);
@@ -227,13 +230,14 @@ export default function EnvironmentsNavbar({ environmentId, session }: Environme
<div className="w-full px-4 sm:px-6">
<div className="flex h-14 justify-between">
<div className="flex space-x-4 py-2">
<div className="flex space-x-4 py-2">
<Link
href={`/environments/${environmentId}/surveys/`}
className=" flex items-center justify-center rounded-md bg-gradient-to-b text-white transition-all ease-in-out hover:scale-105">
{/* <PlusIcon className="h-6 w-6" /> */}
<Image src={FaveIcon} width={30} height={30} alt="faveicon" />
</Link>
{navigation.map((item) => {
const IconComponent: React.ElementType = item.icon;
@@ -245,7 +249,7 @@ export default function EnvironmentsNavbar({ environmentId, session }: Environme
item.current
? "bg-slate-100 text-slate-900"
: "text-slate-900 hover:bg-slate-50 hover:text-slate-900",
"inline-flex items-center rounded-md px-2 py-1 text-sm font-medium"
"hidden items-center rounded-md px-2 py-1 text-sm font-medium sm:inline-flex"
)}
aria-current={item.current ? "page" : undefined}>
<IconComponent className="mr-3 h-5 w-5" />
@@ -254,6 +258,34 @@ export default function EnvironmentsNavbar({ environmentId, session }: Environme
);
})}
</div>
<div className="flex items-center sm:hidden">
<Popover open={mobileNavMenuOpen} onOpenChange={setMobileNavMenuOpen}>
<PopoverTrigger onClick={() => setMobileNavMenuOpen(!mobileNavMenuOpen)}>
<span>
<MenuIcon className="h-6 w-6 rounded-md bg-slate-200 p-1 text-slate-600" />
</span>
</PopoverTrigger>
<PopoverContent className="mr-4 bg-slate-100 shadow">
<div className="flex flex-col">
{navigation.map((navItem) => (
<Link key={navItem.name} href={navItem.href}>
<div
onClick={() => setMobileNavMenuOpen(false)}
className={cn(
"flex items-center space-x-2 rounded-md p-2",
navItem.current && "bg-slate-200"
)}>
<navItem.icon className="h-5 w-5" />
<span className="font-medium text-slate-600">{navItem.name}</span>
</div>
</Link>
))}
</div>
</PopoverContent>
</Popover>
</div>
<div className="hidden sm:ml-6 sm:flex sm:items-center">
<DropdownMenu>
<DropdownMenuTrigger asChild>

File diff suppressed because it is too large Load Diff

View File

@@ -1,27 +1,45 @@
import JsLogo from "@/images/jslogo.png";
import WebhookLogo from "@/images/webhook.png";
import ZapierLogo from "@/images/zapier-small.png";
import { Card } from "@formbricks/ui";
import Image from "next/image";
import JsLogo from "@/images/jslogo.png";
import ZapierLogo from "@/images/zapier-small.png";
export default function IntegrationsPage() {
export default function IntegrationsPage({ params }) {
return (
<div>
<h1 className="my-2 text-3xl font-bold text-slate-800">Integrations</h1>
<p className="mb-6 text-slate-500">Connect Formbricks with your favorite tools.</p>
<div className="grid grid-cols-3 gap-6">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<Card
docsHref="https://formbricks.com/docs/getting-started/nextjs-app"
docsText="Docs"
docsNewTab={true}
label="Javascript Widget"
description="Integrate Formbricks into your Webapp"
icon={<Image src={JsLogo} alt="Javascript Logo" />}
/>
<Card
docsHref="https://formbricks.com/docs/integrations/zapier"
docsText="Docs"
docsNewTab={true}
connectHref="https://zapier.com/apps/formbricks/integrations"
connectText="Connect"
connectNewTab={true}
label="Zapier"
description="Integrate Formbricks with 5000+ apps via Zapier"
icon={<Image src={ZapierLogo} alt="Zapier Logo" />}
/>
<Card
connectHref={`/environments/${params.environmentId}/integrations/webhooks`}
connectText="Manage Webhooks"
connectNewTab={false}
docsHref="https://formbricks.com/docs/webhook-api/overview"
docsText="Docs"
docsNewTab={true}
label="Webhooks"
description="Trigger Webhooks based on actions in your surveys"
icon={<Image src={WebhookLogo} alt="Webhook Logo" />}
/>
</div>
</div>
);

View File

@@ -0,0 +1,231 @@
import SurveyCheckboxGroup from "@/app/(app)/environments/[environmentId]/integrations/webhooks/SurveyCheckboxGroup";
import TriggerCheckboxGroup from "@/app/(app)/environments/[environmentId]/integrations/webhooks/TriggerCheckboxGroup";
import { triggers } from "@/app/(app)/environments/[environmentId]/integrations/webhooks/HardcodedTriggers";
import { testEndpoint } from "@/app/(app)/environments/[environmentId]/integrations/webhooks/testEndpoint";
import Modal from "@/components/shared/Modal";
import { createWebhook } from "@formbricks/lib/services/webhook";
import { TPipelineTrigger } from "@formbricks/types/v1/pipelines";
import { TSurvey } from "@formbricks/types/v1/surveys";
import { TWebhookInput } from "@formbricks/types/v1/webhooks";
import { Button, Input, Label } from "@formbricks/ui";
import clsx from "clsx";
import { Webhook } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useForm } from "react-hook-form";
import toast from "react-hot-toast";
interface AddWebhookModalProps {
environmentId: string;
open: boolean;
surveys: TSurvey[];
setOpen: (v: boolean) => void;
}
export default function AddWebhookModal({ environmentId, surveys, open, setOpen }: AddWebhookModalProps) {
const router = useRouter();
const {
handleSubmit,
reset,
register,
formState: { isSubmitting },
} = useForm();
const [testEndpointInput, setTestEndpointInput] = useState("");
const [hittingEndpoint, setHittingEndpoint] = useState<boolean>(false);
const [endpointAccessible, setEndpointAccessible] = useState<boolean>();
const [selectedTriggers, setSelectedTriggers] = useState<TPipelineTrigger[]>([]);
const [selectedSurveys, setSelectedSurveys] = useState<string[]>([]);
const [selectedAllSurveys, setSelectedAllSurveys] = useState(false);
const [creatingWebhook, setCreatingWebhook] = useState(false);
const handleTestEndpoint = async (sendSuccessToast: boolean) => {
try {
setHittingEndpoint(true);
await testEndpoint(testEndpointInput);
setHittingEndpoint(false);
if (sendSuccessToast) toast.success("Yay! We are able to ping the webhook!");
setEndpointAccessible(true);
return true;
} catch (err) {
setHittingEndpoint(false);
toast.error("Oh no! We are unable to ping the webhook!");
setEndpointAccessible(false);
return false;
}
};
const handleSelectAllSurveys = () => {
setSelectedAllSurveys(!selectedAllSurveys);
setSelectedSurveys([]);
};
const handleSelectedSurveyChange = (surveyId: string) => {
setSelectedSurveys((prevSelectedSurveys: string[]) =>
prevSelectedSurveys.includes(surveyId)
? prevSelectedSurveys.filter((id) => id !== surveyId)
: [...prevSelectedSurveys, surveyId]
);
};
const handleCheckboxChange = (selectedValue: TPipelineTrigger) => {
setSelectedTriggers((prevValues) =>
prevValues.includes(selectedValue)
? prevValues.filter((value) => value !== selectedValue)
: [...prevValues, selectedValue]
);
};
const submitWebhook = async (data: TWebhookInput): Promise<void> => {
if (!isSubmitting) {
try {
setCreatingWebhook(true);
if (!testEndpointInput || testEndpointInput === "") {
throw new Error("Please enter a URL");
}
if (selectedTriggers.length === 0) {
throw new Error("Please select at least one trigger");
}
if (!selectedAllSurveys && selectedSurveys.length === 0) {
throw new Error("Please select at least one survey");
}
const endpointHitSuccessfully = await handleTestEndpoint(false);
if (!endpointHitSuccessfully) return;
const updatedData: TWebhookInput = {
name: data.name,
url: testEndpointInput,
triggers: selectedTriggers,
surveyIds: selectedSurveys,
};
await createWebhook(environmentId, updatedData);
router.refresh();
setOpenWithStates(false);
toast.success("Webhook added successfully.");
} catch (e) {
toast.error(e.message);
} finally {
setCreatingWebhook(false);
}
}
};
const setOpenWithStates = (isOpen: boolean) => {
setOpen(isOpen);
reset();
setTestEndpointInput("");
setEndpointAccessible(undefined);
setSelectedSurveys([]);
setSelectedTriggers([]);
setSelectedAllSurveys(false);
};
return (
<Modal open={open} setOpen={setOpenWithStates} noPadding closeOnOutsideClick={false}>
<div className="flex h-full flex-col rounded-lg">
<div className="rounded-t-lg bg-slate-100">
<div className="flex w-full items-center justify-between p-6">
<div className="flex items-center space-x-2">
<div className="mr-1.5 h-6 w-6 text-slate-500">
<Webhook />
</div>
<div>
<div className="text-xl font-medium text-slate-700">Add Webhook</div>
<div className="text-sm text-slate-500">Send survey response data to a custom endpoint</div>
</div>
</div>
</div>
</div>
<form onSubmit={handleSubmit(submitWebhook)}>
<div className="flex justify-between rounded-lg p-6">
<div className="w-full space-y-4">
<div className="col-span-1">
<Label htmlFor="name">Name</Label>
<div className="mt-1 flex">
<Input
type="text"
id="name"
{...register("name")}
placeholder="Optional: Label your webhook for easy identification"
/>
</div>
</div>
<div className="col-span-1">
<Label htmlFor="URL">URL</Label>
<div className="mt-1 flex">
<Input
type="url"
id="URL"
value={testEndpointInput}
onChange={(e) => {
setTestEndpointInput(e.target.value);
}}
className={clsx(
endpointAccessible === true
? "border-green-500 bg-green-50"
: endpointAccessible === false
? "border-red-200 bg-red-50"
: endpointAccessible === undefined
? "border-slate-200 bg-white"
: null
)}
placeholder="Paste the URL you want the event to trigger on"
/>
<Button
type="button"
variant="secondary"
loading={hittingEndpoint}
className="ml-2 whitespace-nowrap"
onClick={() => {
handleTestEndpoint(true);
}}>
Test Endpoint
</Button>
</div>
</div>
<div>
<Label htmlFor="Triggers">Triggers</Label>
<TriggerCheckboxGroup
triggers={triggers}
selectedTriggers={selectedTriggers}
onCheckboxChange={handleCheckboxChange}
/>
</div>
<div>
<Label htmlFor="Surveys">Surveys</Label>
<SurveyCheckboxGroup
surveys={surveys}
selectedSurveys={selectedSurveys}
selectedAllSurveys={selectedAllSurveys}
onSelectAllSurveys={handleSelectAllSurveys}
onSelectedSurveyChange={handleSelectedSurveyChange}
/>
</div>
</div>
</div>
<div className="flex justify-end border-t border-slate-200 p-6">
<div className="flex space-x-2">
<Button
type="button"
variant="minimal"
onClick={() => {
setOpenWithStates(false);
}}>
Cancel
</Button>
<Button variant="darkCTA" type="submit" loading={creatingWebhook}>
Add Webhook
</Button>
</div>
</div>
</form>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,7 @@
import { TPipelineTrigger } from "@formbricks/types/v1/pipelines";
export const triggers = [
{ title: "Response Created", value: "responseCreated" as TPipelineTrigger },
{ title: "Response Updated", value: "responseUpdated" as TPipelineTrigger },
{ title: "Response Finished", value: "responseFinished" as TPipelineTrigger },
];

View File

@@ -0,0 +1,63 @@
import React from "react";
import { Checkbox } from "@formbricks/ui";
import { TSurvey } from "@formbricks/types/v1/surveys";
interface SurveyCheckboxGroupProps {
surveys: TSurvey[];
selectedSurveys: string[];
selectedAllSurveys: boolean;
onSelectAllSurveys: () => void;
onSelectedSurveyChange: (surveyId: string) => void;
}
export const SurveyCheckboxGroup: React.FC<SurveyCheckboxGroupProps> = ({
surveys,
selectedSurveys,
selectedAllSurveys,
onSelectAllSurveys,
onSelectedSurveyChange,
}) => {
return (
<div className="mt-1 rounded-lg border border-slate-200">
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
<div className="my-1 flex items-center space-x-2">
<Checkbox
type="button"
id="allSurveys"
className="bg-white"
value=""
checked={selectedAllSurveys}
onCheckedChange={onSelectAllSurveys}
/>
<label
htmlFor="allSurveys"
className={`flex cursor-pointer items-center ${selectedAllSurveys ? "font-semibold" : ""}`}>
All current and new surveys
</label>
</div>
{surveys.map((survey) => (
<div key={survey.id} className="my-1 flex items-center space-x-2">
<Checkbox
type="button"
id={survey.id}
value={survey.id}
className="bg-white"
checked={selectedSurveys.includes(survey.id) && !selectedAllSurveys}
disabled={selectedAllSurveys}
onCheckedChange={() => onSelectedSurveyChange(survey.id)}
/>
<label
htmlFor={survey.id}
className={`flex cursor-pointer items-center ${
selectedAllSurveys ? "cursor-not-allowed opacity-50" : ""
}`}>
{survey.name}
</label>
</div>
))}
</div>
</div>
);
};
export default SurveyCheckboxGroup;

View File

@@ -0,0 +1,41 @@
import React from "react";
import { Checkbox } from "@formbricks/ui";
import { TPipelineTrigger } from "@formbricks/types/v1/pipelines";
interface TriggerCheckboxGroupProps {
triggers: { title: string; value: TPipelineTrigger }[];
selectedTriggers: TPipelineTrigger[];
onCheckboxChange: (selectedValue: TPipelineTrigger) => void;
}
export const TriggerCheckboxGroup: React.FC<TriggerCheckboxGroupProps> = ({
triggers,
selectedTriggers,
onCheckboxChange,
}) => {
return (
<div className="mt-1 rounded-lg border border-slate-200">
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
{triggers.map((trigger) => (
<div key={trigger.value} className="my-1 flex items-center space-x-2">
<label htmlFor={trigger.value} className="flex cursor-pointer items-center">
<Checkbox
type="button"
id={trigger.value}
value={trigger.value}
className="bg-white"
checked={selectedTriggers.includes(trigger.value)}
onCheckedChange={() => {
onCheckboxChange(trigger.value);
}}
/>
<span className="ml-2">{trigger.title}</span>
</label>
</div>
))}
</div>
</div>
);
};
export default TriggerCheckboxGroup;

View File

@@ -0,0 +1,47 @@
import ModalWithTabs from "@/components/shared/ModalWithTabs";
import { TWebhook } from "@formbricks/types/v1/webhooks";
import WebhookOverviewTab from "@/app/(app)/environments/[environmentId]/integrations/webhooks/WebhookOverviewTab";
import WebhookSettingsTab from "@/app/(app)/environments/[environmentId]/integrations/webhooks/WebhookSettingsTab";
import { TSurvey } from "@formbricks/types/v1/surveys";
import { Webhook } from "lucide-react";
interface WebhookModalProps {
environmentId: string;
open: boolean;
setOpen: (v: boolean) => void;
webhook: TWebhook;
surveys: TSurvey[];
}
export default function WebhookModal({ environmentId, open, setOpen, webhook, surveys }: WebhookModalProps) {
const tabs = [
{
title: "Overview",
children: <WebhookOverviewTab webhook={webhook} surveys={surveys} />,
},
{
title: "Settings",
children: (
<WebhookSettingsTab
environmentId={environmentId}
webhook={webhook}
surveys={surveys}
setOpen={setOpen}
/>
),
},
];
return (
<>
<ModalWithTabs
open={open}
setOpen={setOpen}
tabs={tabs}
icon={<Webhook />}
label={webhook.name ? webhook.name : webhook.url}
description={""}
/>
</>
);
}

View File

@@ -0,0 +1,83 @@
import { Label } from "@formbricks/ui";
import { convertDateTimeStringShort } from "@formbricks/lib/time";
import { TWebhook } from "@formbricks/types/v1/webhooks";
import { TSurvey } from "@formbricks/types/v1/surveys";
interface ActivityTabProps {
webhook: TWebhook;
surveys: TSurvey[];
}
const getSurveyNamesForWebhook = (webhook: TWebhook, allSurveys: TSurvey[]): string[] => {
if (webhook.surveyIds.length === 0) {
return allSurveys.map((survey) => survey.name);
} else {
return webhook.surveyIds.map((surveyId) => {
const survey = allSurveys.find((survey) => survey.id === surveyId);
return survey ? survey.name : "";
});
}
};
const convertTriggerIdToName = (triggerId: string): string => {
switch (triggerId) {
case "responseCreated":
return "Response Created";
case "responseUpdated":
return "Response Updated";
case "responseFinished":
return "Response Finished";
default:
return triggerId;
}
};
export default function WebhookOverviewTab({ webhook, surveys }: ActivityTabProps) {
return (
<div className="grid grid-cols-3 pb-2">
<div className="col-span-2 space-y-4 pr-6">
<div>
<Label className="text-slate-500">Name</Label>
<p className="text-sm text-slate-900">{webhook.name ? webhook.name : "-"}</p>
</div>
<div>
<Label className="text-slate-500">URL</Label>
<p className="text-sm text-slate-900">{webhook.url}</p>
</div>
<div>
<Label className="text-slate-500">Surveys</Label>
{getSurveyNamesForWebhook(webhook, surveys).map((surveyName, index) => (
<p key={index} className="text-sm text-slate-900">
{surveyName}
</p>
))}
</div>
<div>
<Label className="text-slate-500">Triggers</Label>
{webhook.triggers.map((triggerId) => (
<p key={triggerId} className="text-sm text-slate-900">
{convertTriggerIdToName(triggerId)}
</p>
))}
</div>
</div>
<div className="col-span-1 space-y-3 rounded-lg border border-slate-100 bg-slate-50 p-2">
<div>
<Label className="text-xs font-normal text-slate-500">Created on</Label>
<p className=" text-xs text-slate-700">
{convertDateTimeStringShort(webhook.createdAt?.toString())}
</p>
</div>
<div>
<Label className=" text-xs font-normal text-slate-500">Last updated</Label>
<p className=" text-xs text-slate-700">
{convertDateTimeStringShort(webhook.updatedAt?.toString())}
</p>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,81 @@
import { timeSinceConditionally } from "@formbricks/lib/time";
import { TSurvey } from "@formbricks/types/v1/surveys";
import { TWebhook } from "@formbricks/types/v1/webhooks";
const renderSelectedSurveysText = (webhook: TWebhook, allSurveys: TSurvey[]) => {
if (webhook.surveyIds.length === 0) {
const allSurveyNames = allSurveys.map((survey) => survey.name);
return <p className="text-slate-400">{allSurveyNames.join(", ")}</p>;
} else {
const selectedSurveyNames = webhook.surveyIds.map((surveyId) => {
const survey = allSurveys.find((survey) => survey.id === surveyId);
return survey ? survey.name : "";
});
return <p className="text-slate-400">{selectedSurveyNames.join(", ")}</p>;
}
};
const renderSelectedTriggersText = (webhook: TWebhook) => {
if (webhook.triggers.length === 0) {
return <p className="text-slate-400">No Triggers</p>;
} else {
let cleanedTriggers = webhook.triggers.map((trigger) => {
if (trigger === "responseCreated") {
return "Response Created";
} else if (trigger === "responseUpdated") {
return "Response Updated";
} else if (trigger === "responseFinished") {
return "Response Finished";
} else {
return trigger;
}
});
return (
<p className="text-slate-400">
{cleanedTriggers
.sort((a, b) => {
const triggerOrder = {
"Response Created": 1,
"Response Updated": 2,
"Response Finished": 3,
};
return triggerOrder[a] - triggerOrder[b];
})
.join(", ")}
</p>
);
}
};
export default function WebhookRowData({ webhook, surveys }: { webhook: TWebhook; surveys: TSurvey[] }) {
return (
<div className="mt-2 grid h-16 grid-cols-12 content-center rounded-lg hover:bg-slate-100">
<div className="col-span-4 flex items-center pl-6 text-sm">
<div className="flex items-center">
<div className="text-left">
{webhook.name ? (
<div className="text-left">
<div className="font-medium text-slate-900">{webhook.name}</div>
<div className="text-xs text-slate-400">{webhook.url}</div>
</div>
) : (
<div className="font-medium text-slate-900">{webhook.url}</div>
)}
</div>
</div>
</div>
<div className="col-span-4 my-auto text-center text-sm text-slate-800">
{renderSelectedSurveysText(webhook, surveys)}
</div>
<div className="col-span-2 my-auto text-center text-sm text-slate-800">
{renderSelectedTriggersText(webhook)}
</div>
<div className="col-span-2 my-auto whitespace-nowrap text-center text-sm text-slate-500">
{timeSinceConditionally(webhook.createdAt.toString())}
</div>
<div className="text-center"></div>
</div>
);
}

View File

@@ -0,0 +1,238 @@
"use client";
import DeleteDialog from "@/components/shared/DeleteDialog";
import { Button, Input, Label } from "@formbricks/ui";
import { TrashIcon } from "@heroicons/react/24/outline";
import clsx from "clsx";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "react-hot-toast";
import { TWebhook, TWebhookInput } from "@formbricks/types/v1/webhooks";
import { deleteWebhook, updateWebhook } from "@formbricks/lib/services/webhook";
import { TPipelineTrigger } from "@formbricks/types/v1/pipelines";
import { TSurvey } from "@formbricks/types/v1/surveys";
import { testEndpoint } from "@/app/(app)/environments/[environmentId]/integrations/webhooks/testEndpoint";
import { triggers } from "@/app/(app)/environments/[environmentId]/integrations/webhooks/HardcodedTriggers";
import TriggerCheckboxGroup from "@/app/(app)/environments/[environmentId]/integrations/webhooks/TriggerCheckboxGroup";
import SurveyCheckboxGroup from "@/app/(app)/environments/[environmentId]/integrations/webhooks/SurveyCheckboxGroup";
interface ActionSettingsTabProps {
environmentId: string;
webhook: TWebhook;
surveys: TSurvey[];
setOpen: (v: boolean) => void;
}
export default function WebhookSettingsTab({
environmentId,
webhook,
surveys,
setOpen,
}: ActionSettingsTabProps) {
const router = useRouter();
const { register, handleSubmit } = useForm({
defaultValues: {
name: webhook.name,
url: webhook.url,
triggers: webhook.triggers,
surveyIds: webhook.surveyIds,
},
});
const [openDeleteDialog, setOpenDeleteDialog] = useState(false);
const [isUpdatingWebhook, setIsUpdatingWebhook] = useState(false);
const [selectedTriggers, setSelectedTriggers] = useState<TPipelineTrigger[]>(webhook.triggers);
const [selectedSurveys, setSelectedSurveys] = useState<string[]>(webhook.surveyIds);
const [testEndpointInput, setTestEndpointInput] = useState(webhook.url);
const [endpointAccessible, setEndpointAccessible] = useState<boolean>();
const [hittingEndpoint, setHittingEndpoint] = useState<boolean>(false);
const [selectedAllSurveys, setSelectedAllSurveys] = useState(webhook.surveyIds.length === 0);
const handleTestEndpoint = async (sendSuccessToast: boolean) => {
try {
setHittingEndpoint(true);
await testEndpoint(testEndpointInput);
setHittingEndpoint(false);
if (sendSuccessToast) toast.success("Yay! We are able to ping the webhook!");
setEndpointAccessible(true);
return true;
} catch (err) {
setHittingEndpoint(false);
toast.error("Oh no! We are unable to ping the webhook!");
setEndpointAccessible(false);
return false;
}
};
const handleSelectAllSurveys = () => {
setSelectedAllSurveys(!selectedAllSurveys);
setSelectedSurveys([]);
};
const handleSelectedSurveyChange = (surveyId) => {
setSelectedSurveys((prevSelectedSurveys) => {
if (prevSelectedSurveys.includes(surveyId)) {
return prevSelectedSurveys.filter((id) => id !== surveyId);
} else {
return [...prevSelectedSurveys, surveyId];
}
});
};
const handleCheckboxChange = (selectedValue) => {
setSelectedTriggers((prevValues) => {
if (prevValues.includes(selectedValue)) {
return prevValues.filter((value) => value !== selectedValue);
} else {
return [...prevValues, selectedValue];
}
});
};
const onSubmit = async (data) => {
if (selectedTriggers.length === 0) {
toast.error("Please select at least one trigger");
return;
}
if (!selectedAllSurveys && selectedSurveys.length === 0) {
toast.error("Please select at least one survey");
return;
}
const endpointHitSuccessfully = await handleTestEndpoint(false);
if (!endpointHitSuccessfully) {
return;
}
const updatedData: TWebhookInput = {
name: data.name,
url: data.url as string,
triggers: selectedTriggers,
surveyIds: selectedSurveys,
};
setIsUpdatingWebhook(true);
await updateWebhook(environmentId, webhook.id, updatedData);
toast.success("Webhook updated successfully.");
router.refresh();
setIsUpdatingWebhook(false);
setOpen(false);
};
return (
<div>
<form className="space-y-4" onSubmit={handleSubmit(onSubmit)}>
<div className="col-span-1">
<Label htmlFor="Name">Name</Label>
<div className="mt-1 flex">
<Input
type="text"
id="name"
{...register("name")}
defaultValue={webhook.name ?? ""}
placeholder="Optional: Label your webhook for easy identification"
/>
</div>
</div>
<div className="col-span-1">
<Label htmlFor="URL">URL</Label>
<div className="mt-1 flex">
<Input
{...register("url", {
value: testEndpointInput,
})}
type="text"
value={testEndpointInput}
onChange={(e) => {
setTestEndpointInput(e.target.value);
}}
className={clsx(
endpointAccessible === true
? "border-green-500 bg-green-50"
: endpointAccessible === false
? "border-red-200 bg-red-50"
: endpointAccessible === undefined
? "border-slate-200 bg-white"
: null
)}
placeholder="Paste the URL you want the event to trigger on"
/>
<Button
type="button"
variant="secondary"
loading={hittingEndpoint}
className="ml-2 whitespace-nowrap"
onClick={() => {
handleTestEndpoint(true);
}}>
Test Endpoint
</Button>
</div>
</div>
<div>
<Label htmlFor="Triggers">Triggers</Label>
<TriggerCheckboxGroup
triggers={triggers}
selectedTriggers={selectedTriggers}
onCheckboxChange={handleCheckboxChange}
/>
</div>
<div>
<Label htmlFor="Surveys">Surveys</Label>
<SurveyCheckboxGroup
surveys={surveys}
selectedSurveys={selectedSurveys}
selectedAllSurveys={selectedAllSurveys}
onSelectAllSurveys={handleSelectAllSurveys}
onSelectedSurveyChange={handleSelectedSurveyChange}
/>
</div>
<div className="flex justify-between border-t border-slate-200 py-6">
<div>
<Button
type="button"
variant="warn"
onClick={() => setOpenDeleteDialog(true)}
StartIcon={TrashIcon}
className="mr-3">
Delete
</Button>
<Button
variant="secondary"
href="https://formbricks.com/docs/webhook-api/overview"
target="_blank">
Read Docs
</Button>
</div>
<div className="flex space-x-2">
<Button type="submit" variant="darkCTA" loading={isUpdatingWebhook}>
Save changes
</Button>
</div>
</div>
</form>
<DeleteDialog
open={openDeleteDialog}
setOpen={setOpenDeleteDialog}
deleteWhat={"Webhook"}
text="Are you sure you want to delete this Webhook? This will stop sending you any further notifications."
onDelete={async () => {
setOpen(false);
try {
await deleteWebhook(webhook.id);
router.refresh();
toast.success("Webhook deleted successfully");
} catch (error) {
toast.error("Something went wrong. Please try again.");
}
}}
/>
</div>
);
}

View File

@@ -0,0 +1,96 @@
"use client";
import { Button } from "@formbricks/ui";
import { useState } from "react";
import { TWebhook } from "@formbricks/types/v1/webhooks";
import AddWebhookModal from "@/app/(app)/environments/[environmentId]/integrations/webhooks/AddWebhookModal";
import { TSurvey } from "@formbricks/types/v1/surveys";
import WebhookModal from "@/app/(app)/environments/[environmentId]/integrations/webhooks/WebhookDetailModal";
import { Webhook } from "lucide-react";
import EmptySpaceFiller from "@/components/shared/EmptySpaceFiller";
export default function WebhookTable({
environmentId,
webhooks,
surveys,
children: [TableHeading, webhookRows],
}: {
environmentId: string;
webhooks: TWebhook[];
surveys: TSurvey[];
children: [JSX.Element, JSX.Element[]];
}) {
const [isWebhookDetailModalOpen, setWebhookDetailModalOpen] = useState(false);
const [isAddWebhookModalOpen, setAddWebhookModalOpen] = useState(false);
const [activeWebhook, setActiveWebhook] = useState<TWebhook>({
environmentId,
id: "",
name: "",
url: "",
triggers: [],
surveyIds: [],
createdAt: new Date(),
updatedAt: new Date(),
});
const handleOpenWebhookDetailModalClick = (e, webhook: TWebhook) => {
e.preventDefault();
setActiveWebhook(webhook);
setWebhookDetailModalOpen(true);
};
return (
<>
<div className="mb-6 text-right">
<Button
variant="darkCTA"
onClick={() => {
setAddWebhookModalOpen(true);
}}>
<Webhook className="mr-2 h-5 w-5 text-white" />
Add Webhook
</Button>
</div>
{webhooks.length === 0 ? (
<EmptySpaceFiller
type="table"
environmentId={environmentId}
noWidgetRequired={true}
emptyMessage="Your webhooks will appear here as soon as you add them. ⏲️"
/>
) : (
<div className="rounded-lg border border-slate-200">
{TableHeading}
<div className="grid-cols-7">
{webhooks.map((webhook, index) => (
<button
onClick={(e) => {
handleOpenWebhookDetailModalClick(e, webhook);
}}
className="w-full"
key={webhook.id}>
{webhookRows[index]}
</button>
))}
</div>
</div>
)}
<WebhookModal
environmentId={environmentId}
open={isWebhookDetailModalOpen}
setOpen={setWebhookDetailModalOpen}
webhook={activeWebhook}
surveys={surveys}
/>
<AddWebhookModal
environmentId={environmentId}
surveys={surveys}
open={isAddWebhookModalOpen}
setOpen={setAddWebhookModalOpen}
/>
</>
);
}

View File

@@ -0,0 +1,13 @@
export default function WebhookTableHeading() {
return (
<>
<div className="grid h-12 grid-cols-12 content-center rounded-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
<span className="sr-only">Edit</span>
<div className="col-span-4 pl-6 ">Webhook</div>
<div className="col-span-4 text-center">Surveys</div>
<div className="col-span-2 text-center ">Triggers</div>
<div className="col-span-2 text-center">Updated</div>
</div>
</>
);
}

View File

@@ -0,0 +1,58 @@
import GoBackButton from "@/components/shared/GoBackButton";
import { Button } from "@formbricks/ui";
import { Webhook } from "lucide-react";
export default function Loading() {
return (
<>
<GoBackButton />
<div className="mb-6 text-right">
<Button
variant="darkCTA"
className="pointer-events-none animate-pulse cursor-not-allowed select-none bg-gray-200">
<Webhook className="mr-2 h-5 w-5 text-white" />
Loading
</Button>
</div>
<div className="rounded-lg border border-slate-200">
<div className="grid h-12 grid-cols-12 content-center rounded-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
<span className="sr-only">Edit</span>
<div className="col-span-4 pl-6 ">Webhook</div>
<div className="col-span-4 text-center">Surveys</div>
<div className="col-span-2 text-center ">Triggers</div>
<div className="col-span-2 text-center">Updated</div>
</div>
<div className="grid-cols-7">
{[...Array(3)].map((_, index) => (
<div
key={index}
className="mt-2 grid h-16 grid-cols-12 content-center rounded-lg hover:bg-slate-100">
<div className="col-span-4 flex items-center pl-6 text-sm">
<div className="text-left">
<div className="font-medium text-slate-900">
<div className="mt-0 h-4 w-48 animate-pulse rounded-full bg-gray-200"></div>
</div>
</div>
</div>
<div className="col-span-4 my-auto flex items-center justify-center text-center text-sm text-slate-500">
<div className="font-medium text-slate-500">
<div className="mt-0 h-4 w-36 animate-pulse rounded-full bg-gray-200"></div>
</div>
</div>
<div className="col-span-2 my-auto flex items-center justify-center text-center text-sm text-slate-500">
<div className="font-medium text-slate-500">
<div className="mt-0 h-4 w-24 animate-pulse rounded-full bg-gray-200"></div>
</div>
</div>
<div className="col-span-2 my-auto flex items-center justify-center whitespace-nowrap text-center text-sm text-slate-500">
<div className="h-4 w-16 animate-pulse rounded-full bg-gray-200"></div>
</div>
<div className="text-center"></div>
</div>
))}
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,26 @@
import WebhookRowData from "@/app/(app)/environments/[environmentId]/integrations/webhooks/WebhookRowData";
import WebhookTable from "@/app/(app)/environments/[environmentId]/integrations/webhooks/WebhookTable";
import WebhookTableHeading from "@/app/(app)/environments/[environmentId]/integrations/webhooks/WebhookTableHeading";
import GoBackButton from "@/components/shared/GoBackButton";
import { getSurveys } from "@formbricks/lib/services/survey";
import { getWebhooks } from "@formbricks/lib/services/webhook";
export default async function CustomWebhookPage({ params }) {
const webhooks = (await getWebhooks(params.environmentId)).sort((a, b) => {
if (a.createdAt > b.createdAt) return -1;
if (a.createdAt < b.createdAt) return 1;
return 0;
});
const surveys = await getSurveys(params.environmentId);
return (
<>
<GoBackButton />
<WebhookTable environmentId={params.environmentId} webhooks={webhooks} surveys={surveys}>
<WebhookTableHeading />
{webhooks.map((webhook) => (
<WebhookRowData key={webhook.id} webhook={webhook} surveys={surveys} />
))}
</WebhookTable>
</>
);
}

View File

@@ -0,0 +1,26 @@
"use server";
import "server-only";
export const testEndpoint = async (url: string) => {
try {
const response = await fetch(url, {
method: "POST",
body: JSON.stringify({
formbricks: "test endpoint",
}),
headers: {
"Content-Type": "application/json",
},
});
const statusCode = response.status;
if (statusCode >= 200 && statusCode < 300) {
return true;
} else {
const errorMessage = await response.text();
throw new Error(`Request failed with status code ${statusCode}: ${errorMessage}`);
}
} catch (error) {
throw new Error(`Error while fetching the URL: ${error.message}`);
}
};

View File

@@ -0,0 +1,42 @@
import EmptySpaceFiller from "@/components/shared/EmptySpaceFiller";
import { ActivityItemContent, ActivityItemIcon, ActivityItemPopover } from "./ActivityItemComponents";
import { TActivityFeedItem } from "@formbricks/types/v1/activity";
interface ActivityFeedProps {
activities: TActivityFeedItem[];
sortByDate: boolean;
environmentId: string;
}
export default function ActivityFeed({ activities, sortByDate, environmentId }: ActivityFeedProps) {
const sortedActivities: TActivityFeedItem[] = activities.sort((a, b) =>
sortByDate
? new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
: new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
);
return (
<>
{sortedActivities.length === 0 ? (
<EmptySpaceFiller type={"event"} environmentId={environmentId} />
) : (
<div>
{sortedActivities.map((activityItem) => (
<li key={activityItem.id} className="list-none">
<div className="relative pb-12">
<span className="absolute left-6 top-4 -ml-px h-full w-0.5 bg-slate-200" aria-hidden="true" />
<div className="relative">
<ActivityItemPopover activityItem={activityItem}>
<div className="flex space-x-3 text-left">
<ActivityItemIcon activityItem={activityItem} />
<ActivityItemContent activityItem={activityItem} />
</div>
</ActivityItemPopover>
</div>
</div>
</li>
))}
</div>
)}
</>
);
}

View File

@@ -1,5 +1,5 @@
import { capitalizeFirstLetter } from "@/lib/utils";
import { timeSince } from "@formbricks/lib/time";
import { TActivityFeedItem } from "@formbricks/types/v1/activity";
import { Label, Popover, PopoverContent, PopoverTrigger } from "@formbricks/ui";
import {
CodeBracketIcon,
@@ -9,9 +9,9 @@ import {
SparklesIcon,
TagIcon,
} from "@heroicons/react/24/solid";
import { ActivityFeedItem } from "./ActivityFeed"; // Import the ActivityFeedItem type from the main file
import { formatDistance } from "date-fns";
export const ActivityItemIcon = ({ activityItem }: { activityItem: ActivityFeedItem }) => (
export const ActivityItemIcon = ({ activityItem }: { activityItem: TActivityFeedItem }) => (
<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">
{activityItem.type === "attribute" ? (
<TagIcon />
@@ -19,9 +19,9 @@ export const ActivityItemIcon = ({ activityItem }: { activityItem: ActivityFeedI
<EyeIcon />
) : activityItem.type === "event" ? (
<div>
{activityItem.eventType === "code" && <CodeBracketIcon />}
{activityItem.eventType === "noCode" && <CursorArrowRaysIcon />}
{activityItem.eventType === "automatic" && <SparklesIcon />}
{activityItem.actionType === "code" && <CodeBracketIcon />}
{activityItem.actionType === "noCode" && <CursorArrowRaysIcon />}
{activityItem.actionType === "automatic" && <SparklesIcon />}
</div>
) : (
<QuestionMarkCircleIcon />
@@ -29,7 +29,7 @@ export const ActivityItemIcon = ({ activityItem }: { activityItem: ActivityFeedI
</div>
);
export const ActivityItemContent = ({ activityItem }: { activityItem: ActivityFeedItem }) => (
export const ActivityItemContent = ({ activityItem }: { activityItem: TActivityFeedItem }) => (
<div>
<div className="font-semibold text-slate-700">
{activityItem.type === "attribute" ? (
@@ -37,40 +37,36 @@ export const ActivityItemContent = ({ activityItem }: { activityItem: ActivityFe
) : activityItem.type === "display" ? (
<p>Seen survey</p>
) : activityItem.type === "event" ? (
<p>{activityItem.eventLabel} triggered</p>
<p>{activityItem.actionLabel} triggered</p>
) : (
<p>Unknown Activity</p>
)}
</div>
<div className="text-sm text-slate-400">
<time dateTime={timeSince(activityItem.createdAt)}>{timeSince(activityItem.createdAt)}</time>
<time
dateTime={formatDistance(activityItem.createdAt, new Date(), {
addSuffix: true,
})}>
{formatDistance(activityItem.createdAt, new Date(), {
addSuffix: true,
})}
</time>
</div>
</div>
);
export const ActivityItemPopover = ({
activityItem,
responses,
children,
}: {
activityItem: ActivityFeedItem;
responses: any[];
activityItem: TActivityFeedItem;
children: React.ReactNode;
}) => {
function findMatchingSurveyName(responses, surveyId) {
for (const response of responses) {
if (response.survey.id === surveyId) {
return response.survey.name;
}
return null; // Return null if no match is found
}
}
return (
<Popover>
<PopoverTrigger className="group">{children}</PopoverTrigger>
<PopoverContent className="bg-white">
<div className="">
<div>
{activityItem.type === "attribute" ? (
<div>
<Label className="font-normal text-slate-400">Attribute Label</Label>
@@ -81,26 +77,24 @@ export const ActivityItemPopover = ({
) : activityItem.type === "display" ? (
<div>
<Label className="font-normal text-slate-400">Survey Name</Label>
<p className=" mb-2 text-sm font-medium text-slate-900">
{findMatchingSurveyName(responses, activityItem.displaySurveyId)}
</p>
<p className=" mb-2 text-sm font-medium text-slate-900">{activityItem.displaySurveyName}</p>
</div>
) : activityItem.type === "event" ? (
<div>
<div>
<Label className="font-normal text-slate-400">Event Display Name</Label>
<p className=" mb-2 text-sm font-medium text-slate-900">{activityItem.eventLabel}</p>{" "}
<Label className="font-normal text-slate-400">Event Description</Label>
<Label className="font-normal text-slate-400">Action Display Name</Label>
<p className=" mb-2 text-sm font-medium text-slate-900">{activityItem.actionLabel}</p>{" "}
<Label className="font-normal text-slate-400">Action Description</Label>
<p className=" mb-2 text-sm font-medium text-slate-900">
{activityItem.eventDescription ? (
<span>{activityItem.eventDescription}</span>
{activityItem.actionDescription ? (
<span>{activityItem.actionDescription}</span>
) : (
<span>-</span>
)}
</p>
<Label className="font-normal text-slate-400">Event Type</Label>
<Label className="font-normal text-slate-400">Action Type</Label>
<p className="text-sm font-medium text-slate-900">
{capitalizeFirstLetter(activityItem.eventType)}
{capitalizeFirstLetter(activityItem.actionType)}
</p>
</div>
</div>

View File

@@ -0,0 +1,18 @@
import ActivityTimeline from "@/app/(app)/environments/[environmentId]/people/[personId]/(activitySection)/ActivityTimeline";
import { getActivityTimeline } from "@formbricks/lib/services/activity";
export default async function ActivitySection({
environmentId,
personId,
}: {
environmentId: string;
personId: string;
}) {
const activities = await getActivityTimeline(personId);
return (
<div className="md:col-span-1">
<ActivityTimeline environmentId={environmentId} activities={activities} />
</div>
);
}

View File

@@ -0,0 +1,36 @@
"use client";
import ActivityFeed from "@/app/(app)/environments/[environmentId]/people/[personId]/(activitySection)/ActivityFeed";
import { TActivityFeedItem } from "@formbricks/types/v1/activity";
import { ArrowsUpDownIcon } from "@heroicons/react/24/outline";
import { useState } from "react";
export default function ActivityTimeline({
environmentId,
activities,
}: {
environmentId: string;
activities: TActivityFeedItem[];
}) {
const [activityAscending, setActivityAscending] = useState(true);
const toggleSortActivity = () => {
setActivityAscending(!activityAscending);
};
return (
<>
<div className="flex items-center justify-between pb-6">
<h2 className="text-lg font-bold text-slate-700">Activity Timeline</h2>
<div className="text-right">
<button
onClick={toggleSortActivity}
className="hover:text-brand-dark flex items-center px-1 text-slate-800">
<ArrowsUpDownIcon className="inline h-4 w-4" />
</button>
</div>
</div>
<ActivityFeed activities={activities} sortByDate={activityAscending} environmentId={environmentId} />
</>
);
}

View File

@@ -0,0 +1,68 @@
export const revalidate = REVALIDATION_INTERVAL;
import { REVALIDATION_INTERVAL } from "@formbricks/lib/constants";
import { capitalizeFirstLetter } from "@/lib/utils";
import { getPerson } from "@formbricks/lib/services/person";
import { getResponsesByPersonId } from "@formbricks/lib/services/response";
import { getSessionCount } from "@formbricks/lib/services/session";
export default async function AttributesSection({ personId }: { personId: string }) {
const person = await getPerson(personId);
if (!person) {
throw new Error("No such person found");
}
const numberOfSessions = await getSessionCount(personId);
const responses = await getResponsesByPersonId(personId);
const numberOfResponses = responses?.length || 0;
return (
<div className="space-y-6">
<h2 className="text-lg font-bold text-slate-700">Attributes</h2>
<div>
<dt className="text-sm font-medium text-slate-500">Email</dt>
<dd className="ph-no-capture mt-1 text-sm text-slate-900">
{person.attributes.email ? (
<span>{person.attributes.email}</span>
) : (
<span className="text-slate-300">Not provided</span>
)}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-slate-500">User Id</dt>
<dd className="ph-no-capture mt-1 text-sm text-slate-900">
{person.attributes.userId ? (
<span>{person.attributes.userId}</span>
) : (
<span className="text-slate-300">Not provided</span>
)}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-slate-500">Formbricks Id (internal)</dt>
<dd className="ph-no-capture mt-1 text-sm text-slate-900">{person.id}</dd>
</div>
{Object.entries(person.attributes)
.filter(([key, _]) => key !== "email" && key !== "userId")
.map(([key, value]) => (
<div key={key}>
<dt className="text-sm font-medium text-slate-500">{capitalizeFirstLetter(key.toString())}</dt>
<dd className="mt-1 text-sm text-slate-900">{value}</dd>
</div>
))}
<hr />
<div>
<dt className="text-sm font-medium text-slate-500">Sessions</dt>
<dd className="mt-1 text-sm text-slate-900">{numberOfSessions}</dd>
</div>
<div>
<dt className="text-sm font-medium text-slate-500">Responses</dt>
<dd className="mt-1 text-sm text-slate-900">{numberOfResponses}</dd>
</div>
</div>
);
}

View File

@@ -0,0 +1,30 @@
import ResponseTimeline from "@/app/(app)/environments/[environmentId]/people/[personId]/(responseSection)/ResponseTimeline";
import { getResponsesByPersonId } from "@formbricks/lib/services/response";
import { getSurveys } from "@formbricks/lib/services/survey";
import { TResponseWithSurvey } from "@formbricks/types/v1/responses";
import { TSurvey } from "@formbricks/types/v1/surveys";
export default async function ResponseSection({
environmentId,
personId,
}: {
environmentId: string;
personId: string;
}) {
const responses = await getResponsesByPersonId(personId);
const surveyIds = responses?.map((response) => response.surveyId) || [];
const surveys: TSurvey[] = surveyIds.length === 0 ? [] : (await getSurveys(environmentId)) ?? [];
const responsesWithSurvey: TResponseWithSurvey[] =
responses?.reduce((acc: TResponseWithSurvey[], response) => {
const thisSurvey = surveys.find((survey) => survey?.id === response.surveyId);
if (thisSurvey) {
acc.push({
...response,
survey: thisSurvey,
});
}
return acc;
}, []) || [];
return <ResponseTimeline environmentId={environmentId} responses={responsesWithSurvey} />;
}

View File

@@ -0,0 +1,35 @@
"use client";
import ResponseFeed from "@/app/(app)/environments/[environmentId]/people/[personId]/(responseSection)/ResponsesFeed";
import { TResponseWithSurvey } from "@formbricks/types/v1/responses";
import { ArrowsUpDownIcon } from "@heroicons/react/24/outline";
import { useState } from "react";
export default function ResponseTimeline({
environmentId,
responses,
}: {
environmentId: string;
responses: TResponseWithSurvey[];
}) {
const [responsesAscending, setResponsesAscending] = useState(true);
const toggleSortResponses = () => {
setResponsesAscending(!responsesAscending);
};
return (
<div className="md:col-span-2">
<div className="flex items-center justify-between pb-6">
<h2 className="text-lg font-bold text-slate-700">Responses</h2>
<div className="text-right">
<button
onClick={toggleSortResponses}
className="hover:text-brand-dark flex items-center px-1 text-slate-800">
<ArrowsUpDownIcon className="inline h-4 w-4" />
</button>
</div>
</div>
<ResponseFeed responses={responses} sortByDate={responsesAscending} environmentId={environmentId} />
</div>
);
}

View File

@@ -1,26 +1,35 @@
import { formatDistance } from "date-fns";
import EmptySpaceFiller from "@/components/shared/EmptySpaceFiller";
import SurveyStatusIndicator from "@/components/shared/SurveyStatusIndicator";
import { timeSince } from "@formbricks/lib/time";
import { TResponseWithSurvey } from "@formbricks/types/v1/responses";
import Link from "next/link";
export default function ResponseFeed({ person, sortByDate, environmentId }) {
export default function ResponseFeed({
responses,
sortByDate,
environmentId,
}: {
responses: TResponseWithSurvey[];
sortByDate: boolean;
environmentId: string;
}) {
return (
<>
{person.responses.length === 0 ? (
{responses.length === 0 ? (
<EmptySpaceFiller type="response" environmentId={environmentId} />
) : (
<div>
{person.responses
{responses
.slice()
.sort((a, b) =>
sortByDate
? new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
: new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
)
.map((response, responseIdx) => (
<li key={response.createdAt} className="list-none">
.map((response: TResponseWithSurvey, responseIdx) => (
<li key={response.id} className="list-none">
<div className="relative pb-8">
{responseIdx !== person.responses.length - 1 ? (
{responseIdx !== responses.length - 1 ? (
<span
className="absolute left-4 top-4 -ml-px h-full w-0.5 bg-slate-200"
aria-hidden="true"
@@ -31,8 +40,14 @@ export default function ResponseFeed({ person, sortByDate, environmentId }) {
<div className="px-4 py-5 sm:p-6">
<div className="flex w-full justify-between">
<div className="text-sm text-slate-400">
<time className="text-slate-700" dateTime={timeSince(response.createdAt)}>
{timeSince(response.createdAt)}
<time
className="text-slate-700"
dateTime={formatDistance(response.createdAt, new Date(), {
addSuffix: true,
})}>
{formatDistance(response.createdAt, new Date(), {
addSuffix: true,
})}
</time>
</div>
<div className="flex items-center justify-center space-x-2 rounded-full bg-slate-50 px-3 py-1 text-sm text-slate-600">
@@ -52,8 +67,8 @@ export default function ResponseFeed({ person, sortByDate, environmentId }) {
<div key={question.id}>
<p className="text-sm text-slate-500">{question.headline}</p>
<p className="ph-no-capture my-1 text-lg font-semibold text-slate-700">
{response.data[question.id] instanceof Array
? response.data[question.id].join(", ")
{Array.isArray(response.data[question.id])
? (response.data[question.id] as string[]).join(", ")
: response.data[question.id]}
</p>
</div>

View File

@@ -1,114 +0,0 @@
import EmptySpaceFiller from "@/components/shared/EmptySpaceFiller";
import { useMemo } from "react";
import { ActivityItemContent, ActivityItemIcon, ActivityItemPopover } from "./ActivityItemComponents";
interface ActivityFeedProps {
sessions: any[];
attributes: any[];
displays: any[];
responses: any[];
sortByDate: boolean;
environmentId: string;
}
export type ActivityFeedItem = {
id: string;
type: "event" | "attribute" | "display";
createdAt: string;
updatedAt?: string;
attributeLabel?: string;
attributeValue?: string;
displaySurveyId?: string;
eventLabel?: string;
eventDescription?: string;
eventType?: string;
};
export default function ActivityFeed({
sessions,
attributes,
displays,
responses,
sortByDate,
environmentId,
}: ActivityFeedProps) {
// Convert Attributes into unified format
const unifiedAttributes = useMemo(() => {
if (attributes) {
return attributes.map((attribute) => ({
id: attribute.id,
type: "attribute",
createdAt: attribute.createdAt,
updatedAt: attribute.updatedAt,
attributeLabel: attribute.attributeClass.name,
attributeValue: attribute.value,
}));
}
return [];
}, [attributes]);
// Convert Displays into unified format
const unifiedDisplays = useMemo(() => {
if (displays) {
return displays.map((display) => ({
id: display.id,
type: "display",
createdAt: display.createdAt,
updatedAt: display.updatedAt,
displaySurveyId: display.surveyId,
}));
}
return [];
}, [displays]);
// Convert Events into unified format
const unifiedEvents = useMemo(() => {
if (sessions) {
return sessions.flatMap((session) =>
session.events.map((event) => ({
id: event.id,
type: "event",
eventType: event.eventClass.type,
createdAt: event.createdAt,
eventLabel: event.eventClass.name,
eventDescription: event.eventClass.description,
}))
);
}
return [];
}, [sessions]);
const unifiedList = useMemo<ActivityFeedItem[]>(() => {
return [...unifiedAttributes, ...unifiedDisplays, ...unifiedEvents].sort((a, b) =>
sortByDate
? new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
: new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
);
}, [unifiedAttributes, unifiedDisplays, unifiedEvents, sortByDate]);
return (
<>
{unifiedList.length === 0 ? (
<EmptySpaceFiller type={"event"} environmentId={environmentId} />
) : (
<div>
{unifiedList.map((activityItem) => (
<li key={activityItem.id} className="list-none">
<div className="relative pb-12">
<span className="absolute left-6 top-4 -ml-px h-full w-0.5 bg-slate-200" aria-hidden="true" />
<div className="relative">
<ActivityItemPopover activityItem={activityItem} responses={responses}>
<div className="flex space-x-3 text-left">
<ActivityItemIcon activityItem={activityItem} />
<ActivityItemContent activityItem={activityItem} />
</div>
</ActivityItemPopover>
</div>
</div>
</li>
))}
</div>
)}
</>
);
}

View File

@@ -0,0 +1,52 @@
"use client";
import DeleteDialog from "@/components/shared/DeleteDialog";
import GoBackButton from "@/components/shared/GoBackButton";
import { deletePersonAction } from "./actions";
import { TPerson } from "@formbricks/types/v1/people";
import { TrashIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import toast from "react-hot-toast";
export default function HeadingSection({
environmentId,
person,
}: {
environmentId: string;
person: TPerson;
}) {
const router = useRouter();
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const handleDeletePerson = async () => {
await deletePersonAction(person.id);
router.push(`/environments/${environmentId}/people`);
toast.success("Person deleted successfully.");
};
return (
<>
<GoBackButton />
<div className="flex items-baseline justify-between border-b border-slate-200 pb-6 pt-4">
<h1 className="ph-no-capture text-4xl font-bold tracking-tight text-slate-900">
<span>{person.attributes.email || person.id}</span>
</h1>
<div className="flex items-center space-x-3">
<button
onClick={() => {
setDeleteDialogOpen(true);
}}>
<TrashIcon className="h-5 w-5 text-slate-500 hover:text-red-700" />
</button>
</div>
</div>
<DeleteDialog
open={deleteDialogOpen}
setOpen={setDeleteDialogOpen}
deleteWhat="person"
onDelete={handleDeletePerson}
/>
</>
);
}

View File

@@ -1,183 +0,0 @@
"use client";
import DeleteDialog from "@/components/shared/DeleteDialog";
import GoBackButton from "@/components/shared/GoBackButton";
import LoadingSpinner from "@/components/shared/LoadingSpinner";
import { deletePerson, usePerson } from "@/lib/people/people";
import { capitalizeFirstLetter } from "@/lib/utils";
import { ErrorComponent } from "@formbricks/ui";
import { ArrowsUpDownIcon, TrashIcon } from "@heroicons/react/24/outline";
import { useRouter } from "next/navigation";
import { useMemo, useState } from "react";
import toast from "react-hot-toast";
import ActivityFeed from "./ActivityFeed";
import ResponseFeed from "./ResponsesFeed";
interface PersonDetailsProps {
environmentId: string;
personId: string;
}
export default function PersonDetails({ environmentId, personId }: PersonDetailsProps) {
const router = useRouter();
const { person, isLoadingPerson, isErrorPerson } = usePerson(environmentId, personId);
const [responsesAscending, setResponsesAscending] = useState(true);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [activityAscending, setActivityAscending] = useState(true);
const personEmail = useMemo(
() => person?.attributes?.find((attribute) => attribute.attributeClass.name === "email"),
[person]
);
const personUserId = useMemo(
() => person?.attributes?.find((attribute) => attribute.attributeClass.name === "userId"),
[person]
);
const otherAttributes = useMemo(
() =>
person?.attributes?.filter(
(attribute) =>
attribute.attributeClass.name !== "email" &&
attribute.attributeClass.name !== "userId" &&
!attribute.attributeClass.archived
) as any[],
[person]
);
const toggleSortResponses = () => {
setResponsesAscending(!responsesAscending);
};
const handleDeletePerson = async () => {
await deletePerson(environmentId, personId);
router.push(`/environments/${environmentId}/people`);
toast.success("Person deleted successfully.");
};
const toggleSortActivity = () => {
setActivityAscending(!activityAscending);
};
if (isLoadingPerson) {
return (
<div className="flex h-full w-full items-center justify-center">
<LoadingSpinner />
</div>
);
}
if (isErrorPerson) {
return <ErrorComponent />;
}
return (
<>
<GoBackButton />
<div className="flex items-baseline justify-between border-b border-slate-200 pb-6 pt-4">
<h1 className="ph-no-capture text-4xl font-bold tracking-tight text-slate-900">
{personEmail ? <span>{personEmail.value}</span> : <span>{person.id}</span>}
</h1>
<div className="flex items-center space-x-3">
<button
onClick={() => {
setDeleteDialogOpen(true);
}}>
<TrashIcon className="h-5 w-5 text-slate-500 hover:text-red-700" />
</button>
</div>
</div>
<section className="pb-24 pt-6">
<div className="grid grid-cols-1 gap-x-8 md:grid-cols-4">
<div className="space-y-6">
<h2 className="text-lg font-bold text-slate-700">Attributes</h2>
<div>
<dt className="text-sm font-medium text-slate-500">Email</dt>
<dd className="ph-no-capture mt-1 text-sm text-slate-900">
{personEmail ? (
<span>{personEmail?.value}</span>
) : (
<span className="text-slate-300">Not provided</span>
)}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-slate-500">User Id</dt>
<dd className="ph-no-capture mt-1 text-sm text-slate-900">
{personUserId ? (
<span>{personUserId?.value}</span>
) : (
<span className="text-slate-300">Not provided</span>
)}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-slate-500">Formbricks Id (internal)</dt>
<dd className="ph-no-capture mt-1 text-sm text-slate-900">{person.id}</dd>
</div>
<div>
<dt className="text-sm font-medium text-slate-500">Sessions</dt>
<dd className="mt-1 text-sm text-slate-900">{person.sessions.length}</dd>
</div>
<div>
<dt className="text-sm font-medium text-slate-500">Responses</dt>
<dd className="mt-1 text-sm text-slate-900">{person.responses.length}</dd>
</div>
{otherAttributes.map((attribute) => (
<div key={attribute.attributeClass.name}>
<dt className="text-sm font-medium text-slate-500">
{capitalizeFirstLetter(attribute.attributeClass.name)}
</dt>
<dd className="mt-1 text-sm text-slate-900">{attribute.value}</dd>
</div>
))}
</div>
<div className="md:col-span-2">
<div className="flex items-center justify-between pb-6">
<h2 className="text-lg font-bold text-slate-700">Responses</h2>
<div className="text-right">
<button
onClick={toggleSortResponses}
className="hover:text-brand-dark flex items-center px-1 text-slate-800">
<ArrowsUpDownIcon className="inline h-4 w-4" />
</button>
</div>
</div>
<ResponseFeed person={person} sortByDate={responsesAscending} environmentId={environmentId} />
</div>
<div className="md:col-span-1">
<div className="flex items-center justify-between pb-6">
<h2 className="text-lg font-bold text-slate-700">Activity Timeline</h2>
<div className="text-right">
<button
onClick={toggleSortActivity}
className="hover:text-brand-dark flex items-center px-1 text-slate-800">
<ArrowsUpDownIcon className="inline h-4 w-4" />
</button>
</div>
</div>
<ActivityFeed
sessions={person.sessions}
attributes={person.attributes}
displays={person.displays}
responses={person.responses}
sortByDate={activityAscending}
environmentId={environmentId}
/>
</div>
</div>
</section>
<DeleteDialog
open={deleteDialogOpen}
setOpen={setDeleteDialogOpen}
deleteWhat="person"
onDelete={handleDeletePerson}
/>
</>
);
}

View File

@@ -0,0 +1,7 @@
"use server";
import { deletePerson } from "@formbricks/lib/services/person";
export const deletePersonAction = async (personId: string) => {
await deletePerson(personId);
};

View File

@@ -0,0 +1,145 @@
import { ArrowsUpDownIcon, TrashIcon } from "@heroicons/react/24/outline";
import {
ActivityItemPopover,
ActivityItemIcon,
} from "@/app/(app)/environments/[environmentId]/people/[personId]/(activitySection)/ActivityItemComponents";
import { BackIcon } from "@formbricks/ui";
import { TActivityFeedItem } from "@formbricks/types/v1/activity";
export default function Loading() {
const unifiedList: TActivityFeedItem[] = [
{
id: "clk9o7gnu000kz8kw4nb26o21",
type: "event",
actionType: "noCode",
createdAt: new Date(),
actionLabel: "Loading User Acitivity",
updatedAt: null,
attributeLabel: null,
attributeValue: null,
actionDescription: null,
displaySurveyName: null,
},
{
id: "clk9o7fwc000iz8kw4s0ha0ql",
type: "event",
actionType: "automatic",
createdAt: new Date(),
actionLabel: "Loading User Session Info",
updatedAt: null,
attributeLabel: null,
attributeValue: null,
actionDescription: null,
displaySurveyName: null,
},
];
return (
<div>
<main className="mx-auto px-4 sm:px-6 lg:px-8">
<div className="pointer-events-none animate-pulse cursor-not-allowed select-none">
<button className="inline-flex pt-5 text-sm text-slate-500">
<BackIcon className="mr-2 h-5 w-5" />
Back
</button>
</div>
<div className="flex items-baseline justify-between border-b border-slate-200 pb-6 pt-4">
<h1 className="ph-no-capture text-4xl font-bold tracking-tight text-slate-900">
<span className="animate-pulse rounded-full">Fetching user</span>
</h1>
<div className="flex items-center space-x-3">
<button className="pointer-events-none animate-pulse cursor-not-allowed select-none">
<TrashIcon className="h-5 w-5 text-slate-500 hover:text-red-700" />
</button>
</div>
</div>
<section className="pb-24 pt-6">
<div className="grid grid-cols-1 gap-x-8 md:grid-cols-4">
<div className="space-y-6">
<h2 className="text-lg font-bold text-slate-700">Attributes</h2>
<div>
<dt className="text-sm font-medium text-slate-500">Email</dt>
<dd className="ph-no-capture mt-1 text-sm text-slate-900">
<span className="animate-pulse text-slate-300">Loading</span>
</dd>
</div>
<div>
<dt className="text-sm font-medium text-slate-500">User Id</dt>
<dd className="ph-no-capture mt-1 text-sm text-slate-900">
<span className="animate-pulse text-slate-300">Loading</span>
</dd>
</div>
<div>
<dt className="text-sm font-medium text-slate-500">Formbricks Id (internal)</dt>
<dd className="mt-1 animate-pulse text-sm text-slate-300">Loading</dd>
</div>
<div>
<dt className="text-sm font-medium text-slate-500">Sessions</dt>
<dd className="mt-1 animate-pulse text-sm text-slate-300">Loading</dd>
</div>
<div>
<dt className="text-sm font-medium text-slate-500">Responses</dt>
<dd className="mt-1 animate-pulse text-sm text-slate-300">Loading</dd>
</div>
</div>
<div className="md:col-span-2">
<div className="flex items-center justify-between pb-6">
<h2 className="text-lg font-bold text-slate-700">Responses</h2>
<div className="text-right">
<button className="hover:text-brand-dark pointer-events-none flex animate-pulse cursor-not-allowed select-none items-center px-1 text-slate-800">
<ArrowsUpDownIcon className="inline h-4 w-4" />
</button>
</div>
</div>
<div className="group space-y-4 rounded-lg bg-white p-6 ">
<div className="flex items-center space-x-4">
<div className="h-12 w-12 flex-shrink-0 rounded-full bg-slate-100"></div>
<div className=" h-6 w-full rounded-full bg-slate-100"></div>
</div>
<div className="space-y-4">
<div className="h-12 w-full rounded-full bg-slate-100"></div>
<div className=" flex h-12 w-full items-center justify-center rounded-full bg-slate-50 text-sm text-slate-500 hover:bg-slate-100">
<span className="animate-pulse text-center">Loading user responses</span>
</div>
<div className="h-12 w-full rounded-full bg-slate-50/50"></div>
</div>
</div>
</div>
<div className="md:col-span-1">
<div className="flex items-center justify-between pb-6">
<h2 className="text-lg font-bold text-slate-700">Activity Timeline</h2>
<div className="text-right">
<button className="hover:text-brand-dark pointer-events-none flex animate-pulse cursor-not-allowed select-none items-center px-1 text-slate-800">
<ArrowsUpDownIcon className="inline h-4 w-4" />
</button>
</div>
</div>
<div>
{unifiedList.map((activityItem) => (
<li key={activityItem.id} className="list-none">
<div className="relative pb-12">
<span
className="absolute left-6 top-4 -ml-px h-full w-0.5 bg-slate-200"
aria-hidden="true"
/>
<div className="relative animate-pulse cursor-not-allowed select-none">
<ActivityItemPopover activityItem={activityItem}>
<div className="flex cursor-not-allowed select-none items-center space-x-3">
<ActivityItemIcon activityItem={activityItem} />
<div className="font-semibold text-slate-700">Loading</div>
</div>
</ActivityItemPopover>
</div>
</div>
</li>
))}
</div>
</div>
</div>
</section>
</main>
</div>
);
}

View File

@@ -1,10 +1,31 @@
import PersonDetails from "./PersonDetails";
export const revalidate = REVALIDATION_INTERVAL;
import { REVALIDATION_INTERVAL } from "@formbricks/lib/constants";
import { getPerson } from "@formbricks/lib/services/person";
import AttributesSection from "@/app/(app)/environments/[environmentId]/people/[personId]/(attributeSection)/AttributesSection";
import ActivitySection from "@/app/(app)/environments/[environmentId]/people/[personId]/(activitySection)/ActivitySection";
import HeadingSection from "@/app/(app)/environments/[environmentId]/people/[personId]/HeadingSection";
import ResponseSection from "@/app/(app)/environments/[environmentId]/people/[personId]/(responseSection)/ResponseSection";
export default async function PersonPage({ params }) {
const person = await getPerson(params.personId);
if (!person) {
throw new Error("No such person found");
}
export default function PersonPage({ params }) {
return (
<div>
<main className="mx-auto px-4 sm:px-6 lg:px-8">
<PersonDetails personId={params.personId} environmentId={params.environmentId} />
<>
<HeadingSection environmentId={params.environmentId} person={person} />
<section className="pb-24 pt-6">
<div className="grid grid-cols-1 gap-x-8 md:grid-cols-4">
<AttributesSection personId={params.personId} />
<ResponseSection environmentId={params.environmentId} personId={params.personId} />
<ActivitySection environmentId={params.environmentId} personId={params.personId} />
</div>
</section>
</>
</main>
</div>
);

View File

@@ -26,8 +26,8 @@ export default async function PeoplePage({ params }) {
<div className="rounded-lg border border-slate-200">
<div className="grid h-12 grid-cols-7 content-center rounded-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
<div className="col-span-3 pl-6 ">User</div>
<div className="col-span-2 text-center">User ID</div>
<div className="col-span-2 text-center">Email</div>
<div className="col-span-2 text-center hidden sm:block">User ID</div>
<div className="col-span-2 text-center hidden sm:block">Email</div>
</div>
{people.map((person) => (
<Link
@@ -51,12 +51,12 @@ export default async function PeoplePage({ params }) {
</div>
</div>
</div>
<div className="col-span-2 my-auto whitespace-nowrap text-center text-sm text-slate-500">
<div className="col-span-2 my-auto whitespace-nowrap text-center text-sm text-slate-500 hidden sm:block">
<div className="ph-no-capture text-slate-900">
{truncateMiddle(getAttributeValue(person, "userId"), 24)}
</div>
</div>
<div className="col-span-2 my-auto whitespace-nowrap text-center text-sm text-slate-500">
<div className="col-span-2 my-auto whitespace-nowrap text-center text-sm text-slate-500 hidden sm:block">
<div className="ph-no-capture text-slate-900">{getAttributeValue(person, "email")}</div>
</div>
</div>

View File

@@ -1,19 +1,15 @@
"use client";
import LoadingSpinner from "@/components/shared/LoadingSpinner";
import { useProduct } from "@/lib/products/products";
import { ErrorComponent } from "@formbricks/ui";
import EditApiKeys from "./EditApiKeys";
import { getProductByEnvironmentId } from "@formbricks/lib/services/product";
import { getApiKeys } from "@formbricks/lib/services/apiKey";
import { getEnvironments } from "@formbricks/lib/services/environment";
export default function ApiKeyList({
export default async function ApiKeyList({
environmentId,
environmentType,
}: {
environmentId: string;
environmentType: string;
}) {
const { product, isLoadingProduct, isErrorProduct } = useProduct(environmentId);
const findEnvironmentByType = (environments, targetType) => {
for (const environment of environments) {
if (environment.type === targetType) {
@@ -23,15 +19,12 @@ export default function ApiKeyList({
return null;
};
if (isLoadingProduct) {
return <LoadingSpinner />;
}
const product = await getProductByEnvironmentId(environmentId);
const environments = await getEnvironments(product.id);
const environmentTypeId = findEnvironmentByType(environments, environmentType);
const apiKeys = await getApiKeys(environmentTypeId);
if (isErrorProduct) {
<ErrorComponent />;
}
const environmentTypeId = findEnvironmentByType(product?.environments, environmentType);
return <EditApiKeys environmentTypeId={environmentTypeId} environmentType={environmentType} />;
return (
<EditApiKeys environmentTypeId={environmentTypeId} environmentType={environmentType} apiKeys={apiKeys} />
);
}

View File

@@ -1,28 +1,28 @@
"use client";
import DeleteDialog from "@/components/shared/DeleteDialog";
import LoadingSpinner from "@/components/shared/LoadingSpinner";
import { createApiKey, deleteApiKey, useApiKeys } from "@/lib/apiKeys";
import { capitalizeFirstLetter } from "@/lib/utils";
import { timeSince } from "@formbricks/lib/time";
import { Button, ErrorComponent } from "@formbricks/ui";
import { TApiKey } from "@formbricks/types/v1/apiKeys";
import { Button } from "@formbricks/ui";
import { TrashIcon } from "@heroicons/react/24/outline";
import { useState } from "react";
import toast from "react-hot-toast";
import AddAPIKeyModal from "./AddApiKeyModal";
import { createApiKeyAction, deleteApiKeyAction } from "./actions";
export default function EditAPIKeys({
environmentTypeId,
environmentType,
apiKeys,
}: {
environmentTypeId: string;
environmentType: string;
apiKeys: TApiKey[];
}) {
const { apiKeys, mutateApiKeys, isLoadingApiKeys, isErrorApiKeys } = useApiKeys(environmentTypeId);
const [isAddAPIKeyModalOpen, setOpenAddAPIKeyModal] = useState(false);
const [isDeleteKeyModalOpen, setOpenDeleteKeyModal] = useState(false);
const [apiKeysLocal, setApiKeysLocal] = useState<TApiKey[]>(apiKeys);
const [activeKey, setActiveKey] = useState({} as any);
const handleOpenDeleteKeyModal = (e, apiKey) => {
@@ -32,26 +32,21 @@ export default function EditAPIKeys({
};
const handleDeleteKey = async () => {
await deleteApiKey(environmentTypeId, activeKey);
mutateApiKeys();
await deleteApiKeyAction(activeKey.id);
const updatedApiKeys = apiKeysLocal?.filter((apiKey) => apiKey.id !== activeKey.id) || [];
setApiKeysLocal(updatedApiKeys);
setOpenDeleteKeyModal(false);
toast.success("API Key deleted");
};
const handleAddAPIKey = async (data) => {
const apiKey = await createApiKey(environmentTypeId, { label: data.label });
mutateApiKeys([...JSON.parse(JSON.stringify(apiKeys)), apiKey], false);
const apiKey = await createApiKeyAction(environmentTypeId, { label: data.label });
const updatedApiKeys = [...apiKeysLocal!, apiKey];
setApiKeysLocal(updatedApiKeys);
setOpenAddAPIKeyModal(false);
toast.success("API key created");
};
if (isLoadingApiKeys) {
return <LoadingSpinner />;
}
if (isErrorApiKeys) {
<ErrorComponent />;
}
return (
<>
<div className="mb-6 text-right">
@@ -72,19 +67,22 @@ export default function EditAPIKeys({
<div className=""></div>
</div>
<div className="grid-cols-9">
{apiKeys.length === 0 ? (
{apiKeysLocal && apiKeysLocal.length === 0 ? (
<div className="flex h-12 items-center justify-center whitespace-nowrap px-6 text-sm font-medium text-slate-400 ">
You don&apos;t have any API keys yet
</div>
) : (
apiKeys.map((apiKey) => (
apiKeysLocal &&
apiKeysLocal.map((apiKey) => (
<div
className="grid h-12 w-full grid-cols-9 content-center rounded-lg px-6 text-left text-sm text-slate-900"
key={apiKey.hashedKey}>
<div className="col-span-2 font-semibold">{apiKey.label}</div>
<div className="col-span-2">{apiKey.apiKey || <span className="italic">secret</span>}</div>
<div className="col-span-2">{apiKey.lastUsed && timeSince(apiKey.lastUsed)}</div>
<div className="col-span-2">{timeSince(apiKey.createdAt)}</div>
<div className="col-span-2">
{apiKey.lastUsedAt && timeSince(apiKey.lastUsedAt.toString())}
</div>
<div className="col-span-2">{timeSince(apiKey.createdAt.toString())}</div>
<div className="col-span-1 text-center">
<button onClick={(e) => handleOpenDeleteKeyModal(e, apiKey)}>
<TrashIcon className="h-5 w-5 text-slate-700 hover:text-slate-500" />

View File

@@ -0,0 +1,11 @@
"use server";
import { deleteApiKey, createApiKey } from "@formbricks/lib/services/apiKey";
import { TApiKeyCreateInput } from "@formbricks/types/v1/apiKeys";
export async function deleteApiKeyAction(id: string) {
return await deleteApiKey(id);
}
export async function createApiKeyAction(environmentId: string, apiKeyData: TApiKeyCreateInput) {
return await createApiKey(environmentId, apiKeyData);
}

View File

@@ -0,0 +1,50 @@
function LoadingCard({ title, description }) {
return (
<div className="my-4 rounded-lg border border-slate-200">
<div className="grid content-center rounded-lg bg-slate-100 px-6 py-5 text-left text-slate-900">
<h3 className="text-lg font-medium leading-6">{title}</h3>
<p className="mt-1 text-sm text-slate-500">{description}</p>
</div>
<div className="w-full">
<div className="rounded-lg px-6 py-5 hover:bg-slate-100">
<div className="flex justify-end">
<div className="mt-4 h-6 w-28 animate-pulse rounded-full bg-gray-200"></div>
</div>
<div className="mt-6 rounded-lg border border-slate-200">
<div className="grid h-12 grid-cols-9 content-center rounded-t-lg bg-slate-100 px-6 text-left text-sm font-semibold text-slate-900">
<div className="col-span-2">Label</div>
<div className="col-span-2">API Key</div>
<div className="col-span-2">Last used</div>
<div className="col-span-2">Created at</div>
</div>
<div className="px-6">
<div className="my-4 h-6 w-full animate-pulse rounded-full bg-gray-200"></div>
<div className="my-4 h-6 w-full animate-pulse rounded-full bg-gray-200"></div>
</div>
</div>
</div>
</div>
</div>
);
}
export default function Loading() {
const cards = [
{
title: "Development Env Keys",
description: "Add and remove API keys for your Development environment.",
},
{
title: "Production Env Keys",
description: "Add and remove API keys for your Production environment.",
},
];
return (
<div>
<h2 className="my-4 text-2xl font-medium leading-6 text-slate-800">API Keys</h2>
{cards.map((card, index) => (
<LoadingCard key={index} {...card} />
))}
</div>
);
}

View File

@@ -1,3 +1,6 @@
export const revalidate = REVALIDATION_INTERVAL;
import { REVALIDATION_INTERVAL } from "@formbricks/lib/constants";
import SettingsCard from "../SettingsCard";
import SettingsTitle from "../SettingsTitle";
import ApiKeyList from "./ApiKeyList";

View File

@@ -51,7 +51,8 @@ export default function PricingTable({ environmentId, session }: PricingTablePro
"Unlimited surveys",
"Unlimited team members",
"Remove branding",
"100 responses per survey",
"Unlimited link survey responses",
"100 responses per web-app survey",
"Granular targeting",
"In-product surveys",
"Link surveys",

View File

@@ -0,0 +1,41 @@
"use client";
import { TProduct, TProductUpdateInput } from "@formbricks/types/v1/product";
import { Button, ColorPicker, Label } from "@formbricks/ui";
import { useState } from "react";
import toast from "react-hot-toast";
import { updateProductAction } from "./actions";
interface EditBrandColorProps {
product: TProduct;
}
export function EditBrandColor({ product }: EditBrandColorProps) {
const [color, setColor] = useState(product.brandColor);
const [updatingColor, setUpdatingColor] = useState(false);
const handleUpdateBrandColor = async () => {
try {
setUpdatingColor(true);
let inputProduct: Partial<TProductUpdateInput> = {
brandColor: color,
};
await updateProductAction(product.id, inputProduct);
toast.success("Brand color updated successfully.");
} catch (error) {
toast.error(`Error: ${error.message}`);
} finally {
setUpdatingColor(false);
}
};
return (
<div className="w-full max-w-sm items-center">
<Label htmlFor="brandcolor">Color (HEX)</Label>
<ColorPicker color={color} onChange={setColor} />
<Button variant="darkCTA" className="mt-4" loading={updatingColor} onClick={handleUpdateBrandColor}>
Save
</Button>
</div>
);
}

View File

@@ -0,0 +1,95 @@
"use client";
import { cn } from "@formbricks/lib/cn";
import { Button, ColorPicker, Label, Switch } from "@formbricks/ui";
import { useState } from "react";
import toast from "react-hot-toast";
import { DEFAULT_BRAND_COLOR } from "@formbricks/lib/constants";
import { TProduct, TProductUpdateInput } from "@formbricks/types/v1/product";
import { updateProductAction } from "./actions";
interface EditHighlightBorderProps {
product: TProduct;
}
export const EditHighlightBorder = ({ product }: EditHighlightBorderProps) => {
const [showHighlightBorder, setShowHighlightBorder] = useState(product.highlightBorderColor ? true : false);
const [color, setColor] = useState<string | null>(product.highlightBorderColor || DEFAULT_BRAND_COLOR);
const [updatingBorder, setUpdatingBorder] = useState(false);
const handleUpdateHighlightBorder = async () => {
try {
setUpdatingBorder(true);
let inputProduct: Partial<TProductUpdateInput> = {
highlightBorderColor: color,
};
await updateProductAction(product.id, inputProduct);
toast.success("Border color updated successfully.");
} catch (error) {
toast.error(`Error: ${error.message}`);
} finally {
setUpdatingBorder(false);
}
};
const handleSwitch = (checked: boolean) => {
if (checked) {
if (!color) {
setColor(DEFAULT_BRAND_COLOR);
setShowHighlightBorder(true);
} else {
setShowHighlightBorder(true);
}
} else {
setShowHighlightBorder(false);
setColor(null);
}
};
return (
<div className="flex min-h-full w-full">
<div className="flex w-1/2 flex-col px-6 py-5">
<div className="mb-6 flex items-center space-x-2">
<Switch id="highlightBorder" checked={showHighlightBorder} onCheckedChange={handleSwitch} />
<h2 className="text-sm font-medium text-slate-800">Show highlight border</h2>
</div>
{showHighlightBorder && color ? (
<>
<Label htmlFor="brandcolor">Color (HEX)</Label>
<ColorPicker color={color} onChange={setColor} />
</>
) : null}
<Button
variant="darkCTA"
className="mt-4 flex max-w-[80px] items-center justify-center"
loading={updatingBorder}
onClick={handleUpdateHighlightBorder}>
Save
</Button>
</div>
<div className="flex w-1/2 flex-col items-center justify-center gap-4 bg-slate-200 px-6 py-5">
<h3 className="text-slate-500">Preview</h3>
<div
className={cn("flex flex-col gap-4 rounded-lg border-2 bg-white p-5")}
{...(showHighlightBorder &&
color && {
style: {
borderColor: color,
},
})}>
<h3 className="text-sm font-semibold text-slate-800">How easy was it for you to do this?</h3>
<div className="flex rounded-2xl border border-slate-400">
{[1, 2, 3, 4, 5].map((num) => (
<div key={num} className="border-r border-slate-400 px-6 py-5 last:border-r-0">
<span className="text-sm font-medium">{num}</span>
</div>
))}
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,117 @@
"use client";
import { cn } from "@formbricks/lib/cn";
import { Button, Label, RadioGroup, RadioGroupItem } from "@formbricks/ui";
import { useState } from "react";
import toast from "react-hot-toast";
import { getPlacementStyle } from "@/lib/preview";
import { PlacementType } from "@formbricks/types/js";
import { TProduct, TProductUpdateInput } from "@formbricks/types/v1/product";
import { updateProductAction } from "./actions";
const placements = [
{ name: "Bottom Right", value: "bottomRight", disabled: false },
{ name: "Top Right", value: "topRight", disabled: false },
{ name: "Top Left", value: "topLeft", disabled: false },
{ name: "Bottom Left", value: "bottomLeft", disabled: false },
{ name: "Centered Modal", value: "center", disabled: false },
];
interface EditPlacementProps {
product: TProduct;
}
export function EditPlacement({ product }: EditPlacementProps) {
const [currentPlacement, setCurrentPlacement] = useState<PlacementType>(product.placement);
const [overlay, setOverlay] = useState(product.darkOverlay ? "darkOverlay" : "lightOverlay");
const [clickOutside, setClickOutside] = useState(product.clickOutsideClose ? "allow" : "disallow");
const [updatingPlacement, setUpdatingPlacement] = useState(false);
const handleUpdatePlacement = async () => {
try {
setUpdatingPlacement(true);
let inputProduct: Partial<TProductUpdateInput> = {
placement: currentPlacement,
darkOverlay: overlay === "darkOverlay",
clickOutsideClose: clickOutside === "allow",
};
await updateProductAction(product.id, inputProduct);
toast.success("Placement updated successfully.");
} catch (error) {
toast.error(`Error: ${error.message}`);
} finally {
setUpdatingPlacement(false);
}
};
return (
<div className="w-full items-center">
<div className="flex">
<RadioGroup onValueChange={(e) => setCurrentPlacement(e as PlacementType)} value={currentPlacement}>
{placements.map((placement) => (
<div key={placement.value} className="flex items-center space-x-2 whitespace-nowrap">
<RadioGroupItem id={placement.value} value={placement.value} disabled={placement.disabled} />
<Label
htmlFor={placement.value}
className={cn(placement.disabled ? "cursor-not-allowed text-slate-500" : "text-slate-900")}>
{placement.name}
</Label>
</div>
))}
</RadioGroup>
<div className="relative ml-8 h-40 w-full rounded bg-slate-200">
<div
className={cn(
"absolute h-16 w-16 rounded bg-slate-700",
getPlacementStyle(currentPlacement)
)}></div>
</div>
</div>
{currentPlacement === "center" && (
<>
<div className="mt-6 space-y-2">
<Label className="font-semibold">Centered modal overlay color</Label>
<RadioGroup onValueChange={(e) => setOverlay(e)} value={overlay} className="flex space-x-4">
<div className="flex items-center space-x-2 whitespace-nowrap">
<RadioGroupItem id="lightOverlay" value="lightOverlay" />
<Label htmlFor="lightOverlay" className="text-slate-900">
Light Overlay
</Label>
</div>
<div className="flex items-center space-x-2 whitespace-nowrap">
<RadioGroupItem id="darkOverlay" value="darkOverlay" />
<Label htmlFor="darkOverlay" className="text-slate-900">
Dark Overlay
</Label>
</div>
</RadioGroup>
</div>
<div className="mt-6 space-y-2">
<Label className="font-semibold">Allow users to exit by clicking outside the study</Label>
<RadioGroup
onValueChange={(e) => setClickOutside(e)}
value={clickOutside}
className="flex space-x-4">
<div className="flex items-center space-x-2 whitespace-nowrap">
<RadioGroupItem id="disallow" value="disallow" />
<Label htmlFor="disallow" className="text-slate-900">
Don&apos;t Allow
</Label>
</div>
<div className="flex items-center space-x-2 whitespace-nowrap">
<RadioGroupItem id="allow" value="allow" />
<Label htmlFor="allow" className="text-slate-900">
Allow
</Label>
</div>
</RadioGroup>
</div>
</>
)}
<Button variant="darkCTA" className="mt-4" loading={updatingPlacement} onClick={handleUpdatePlacement}>
Save
</Button>
</div>
);
}

View File

@@ -0,0 +1,49 @@
"use client";
import { updateProductAction } from "./actions";
import { TProduct, TProductUpdateInput } from "@formbricks/types/v1/product";
import { Label, Switch } from "@formbricks/ui";
import { useState } from "react";
import toast from "react-hot-toast";
interface EditSignatureProps {
product: TProduct;
}
export function EditFormbricksSignature({ product }: EditSignatureProps) {
const [formbricksSignature, setFormbricksSignature] = useState(product.formbricksSignature);
const [updatingSignature, setUpdatingSignature] = useState(false);
const toggleSignature = async () => {
try {
setUpdatingSignature(true);
const newSignatureState = !formbricksSignature;
setFormbricksSignature(newSignatureState);
let inputProduct: Partial<TProductUpdateInput> = {
formbricksSignature: newSignatureState,
};
await updateProductAction(product.id, inputProduct);
toast.success(
newSignatureState ? "Formbricks signature will be shown." : "Formbricks signature will now be hidden."
);
} catch (error) {
toast.error(`Error: ${error.message}`);
} finally {
setUpdatingSignature(false);
}
};
return (
<div className="w-full items-center">
<div className="flex items-center space-x-2">
<Switch
id="signature"
checked={formbricksSignature}
onCheckedChange={toggleSignature}
disabled={updatingSignature}
/>
<Label htmlFor="signature">Show &apos;Powered by Formbricks&apos; Signature</Label>
</div>
</div>
);
}

View File

@@ -0,0 +1,8 @@
"use server";
import { updateProduct } from "@formbricks/lib/services/product";
import { TProductUpdateInput } from "@formbricks/types/v1/product";
export async function updateProductAction(productId: string, inputProduct: Partial<TProductUpdateInput>) {
return await updateProduct(productId, inputProduct);
}

View File

@@ -1,233 +0,0 @@
"use client";
import { cn } from "@formbricks/lib/cn";
import LoadingSpinner from "@/components/shared/LoadingSpinner";
import { useEnvironment } from "@/lib/environments/environments";
import { useProductMutation } from "@/lib/products/mutateProducts";
import { useProduct } from "@/lib/products/products";
import {
Button,
ColorPicker,
ErrorComponent,
Label,
RadioGroup,
RadioGroupItem,
Switch,
} from "@formbricks/ui";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { getPlacementStyle } from "@/lib/preview";
import { PlacementType } from "@formbricks/types/js";
export function EditBrandColor({ environmentId }) {
const { product, isLoadingProduct, isErrorProduct } = useProduct(environmentId);
const { triggerProductMutate, isMutatingProduct } = useProductMutation(environmentId);
const [color, setColor] = useState("");
useEffect(() => {
if (product) setColor(product.brandColor);
}, [product]);
if (isLoadingProduct) {
return <LoadingSpinner />;
}
if (isErrorProduct) {
return <div>Error</div>;
}
return (
<div className="w-full max-w-sm items-center">
<Label htmlFor="brandcolor">Color (HEX)</Label>
<ColorPicker color={color} onChange={setColor} />
<Button
type="submit"
variant="darkCTA"
className="mt-4"
loading={isMutatingProduct}
onClick={() => {
triggerProductMutate({ brandColor: color })
.then(() => {
toast.success("Brand color updated successfully.");
})
.catch((error) => {
toast.error(`Error: ${error.message}`);
});
}}>
Save
</Button>
</div>
);
}
export function EditPlacement({ environmentId }) {
const { product, isLoadingProduct, isErrorProduct } = useProduct(environmentId);
const { triggerProductMutate, isMutatingProduct } = useProductMutation(environmentId);
const [currentPlacement, setCurrentPlacement] = useState<PlacementType>("bottomRight");
const [overlay, setOverlay] = useState("");
const [clickOutside, setClickOutside] = useState("");
useEffect(() => {
if (product) {
setCurrentPlacement(product.placement);
setOverlay(product.darkOverlay ? "darkOverlay" : "lightOverlay");
setClickOutside(product.clickOutsideClose ? "allow" : "disallow");
}
}, [product]);
if (isLoadingProduct) {
return <LoadingSpinner />;
}
if (isErrorProduct) {
return <ErrorComponent />;
}
const placements = [
{ name: "Bottom Right", value: "bottomRight", disabled: false },
{ name: "Top Right", value: "topRight", disabled: false },
{ name: "Top Left", value: "topLeft", disabled: false },
{ name: "Bottom Left", value: "bottomLeft", disabled: false },
{ name: "Centered Modal", value: "center", disabled: false },
];
return (
<div className="w-full items-center">
<div className="flex">
<RadioGroup onValueChange={(e) => setCurrentPlacement(e as PlacementType)} value={currentPlacement}>
{placements.map((placement) => (
<div key={placement.value} className="flex items-center space-x-2 whitespace-nowrap">
<RadioGroupItem id={placement.value} value={placement.value} disabled={placement.disabled} />
<Label
htmlFor={placement.value}
className={cn(placement.disabled ? "cursor-not-allowed text-slate-500" : "text-slate-900")}>
{placement.name}
</Label>
</div>
))}
</RadioGroup>
<div className="relative ml-8 h-40 w-full rounded bg-slate-200">
<div
className={cn(
"absolute h-16 w-16 rounded bg-slate-700",
getPlacementStyle(currentPlacement)
)}></div>
</div>
</div>
{currentPlacement === "center" && (
<>
<div className="mt-6 space-y-2">
<Label className="font-semibold">Centered modal overlay color</Label>
<RadioGroup onValueChange={(e) => setOverlay(e)} value={overlay} className="flex space-x-4">
<div className="flex items-center space-x-2 whitespace-nowrap">
<RadioGroupItem id="lightOverlay" value="lightOverlay" />
<Label htmlFor="lightOverlay" className="text-slate-900">
Light Overlay
</Label>
</div>
<div className="flex items-center space-x-2 whitespace-nowrap">
<RadioGroupItem id="darkOverlay" value="darkOverlay" />
<Label htmlFor="darkOverlay" className="text-slate-900">
Dark Overlay
</Label>
</div>
</RadioGroup>
</div>
<div className="mt-6 space-y-2">
<Label className="font-semibold">Allow users to exit by clicking outside the study</Label>
<RadioGroup
onValueChange={(e) => setClickOutside(e)}
value={clickOutside}
className="flex space-x-4">
<div className="flex items-center space-x-2 whitespace-nowrap">
<RadioGroupItem id="disallow" value="disallow" />
<Label htmlFor="disallow" className="text-slate-900">
Don&apos;t Allow
</Label>
</div>
<div className="flex items-center space-x-2 whitespace-nowrap">
<RadioGroupItem id="allow" value="allow" />
<Label htmlFor="allow" className="text-slate-900">
Allow
</Label>
</div>
</RadioGroup>
</div>
</>
)}
<Button
type="submit"
variant="darkCTA"
className="mt-4"
loading={isMutatingProduct}
onClick={() => {
triggerProductMutate({
placement: currentPlacement,
darkOverlay: overlay === "darkOverlay",
clickOutsideClose: clickOutside === "allow",
})
.then(() => {
toast.success("Placement updated successfully.");
})
.catch((error) => {
toast.error(`Error: ${error.message}`);
});
}}>
Save
</Button>
</div>
);
}
export function EditFormbricksSignature({ environmentId }) {
const { isLoadingEnvironment, isErrorEnvironment } = useEnvironment(environmentId);
const { product, isLoadingProduct, isErrorProduct } = useProduct(environmentId);
const { triggerProductMutate, isMutatingProduct } = useProductMutation(environmentId);
const [formbricksSignature, setFormbricksSignature] = useState(false);
useEffect(() => {
if (product) {
setFormbricksSignature(product.formbricksSignature);
}
}, [product]);
const toggleSignature = () => {
const newSignatureState = !formbricksSignature;
setFormbricksSignature(newSignatureState);
triggerProductMutate({ formbricksSignature: newSignatureState })
.then(() => {
toast.success(newSignatureState ? "Formbricks signature shown." : "Formbricks signature hidden.");
})
.catch((error) => {
toast.error(`Error: ${error.message}`);
});
};
if (isLoadingEnvironment || isLoadingProduct) {
return <LoadingSpinner />;
}
if (isErrorEnvironment || isErrorProduct) {
return <ErrorComponent />;
}
if (formbricksSignature !== null) {
return (
<div className="w-full items-center">
<div className="flex items-center space-x-2">
<Switch
id="signature"
checked={formbricksSignature}
onCheckedChange={toggleSignature}
disabled={isMutatingProduct}
/>
<Label htmlFor="signature">Show &apos;Powered by Formbricks&apos; Signature</Label>
</div>
</div>
);
}
return null;
}

View File

@@ -0,0 +1,114 @@
import SettingsCard from "@/app/(app)/environments/[environmentId]/settings/SettingsCard";
import SettingsTitle from "@/app/(app)/environments/[environmentId]/settings/SettingsTitle";
import { cn } from "@formbricks/lib/cn";
import { Button, Label, RadioGroup, RadioGroupItem, Switch } from "@formbricks/ui";
const placements = [
{ name: "Bottom Right", value: "bottomRight", disabled: false },
{ name: "Top Right", value: "topRight", disabled: false },
{ name: "Top Left", value: "topLeft", disabled: false },
{ name: "Bottom Left", value: "bottomLeft", disabled: false },
{ name: "Centered Modal", value: "center", disabled: false },
];
export default function Loading() {
return (
<div>
<SettingsTitle title="Look & Feel" />
<SettingsCard title="Brand Color" description="Match the surveys with your user interface.">
<div className="w-full max-w-sm items-center">
<Label htmlFor="brandcolor">Color (HEX)</Label>
<div className="my-2">
<div className="flex w-full items-center justify-between space-x-1 rounded-md border border-slate-300 px-2 text-sm text-slate-400">
<div className="ml-2 mr-2 h-10 w-32 border-0 bg-transparent text-slate-500 outline-none focus:border-none"></div>
</div>
</div>
<Button
variant="darkCTA"
className="pointer-events-none mt-4 animate-pulse cursor-not-allowed select-none bg-gray-200">
Loading
</Button>
</div>
</SettingsCard>
<SettingsCard
title="In-app Survey Placement"
description="Change where surveys will be shown in your web app.">
<div className="w-full items-center">
<div className="flex cursor-not-allowed select-none">
<RadioGroup>
{placements.map((placement) => (
<div key={placement.value} className="flex items-center space-x-2 whitespace-nowrap ">
<RadioGroupItem
className="cursor-not-allowed select-none"
id={placement.value}
value={placement.value}
disabled={placement.disabled}
/>
<Label
htmlFor={placement.value}
className={cn(
placement.disabled ? "cursor-not-allowed text-slate-500" : "text-slate-900"
)}>
{placement.name}
</Label>
</div>
))}
</RadioGroup>
<div className="relative ml-8 h-40 w-full rounded bg-slate-200">
<div className={cn("absolute bottom-3 h-16 w-16 rounded bg-slate-700 sm:right-3")}></div>
</div>
</div>
<Button
variant="darkCTA"
className="pointer-events-none mt-4 animate-pulse cursor-not-allowed select-none bg-gray-200">
Loading
</Button>
</div>
</SettingsCard>
<SettingsCard
noPadding
title="Highlight Border"
description="Make sure your users notice the survey you display">
<div className="flex min-h-full w-full">
<div className="flex w-1/2 flex-col px-6 py-5">
<div className="pointer-events-none mb-6 flex cursor-not-allowed select-none items-center space-x-2">
<Switch id="highlightBorder" checked={false} />
<h2 className="text-sm font-medium text-slate-800">Show highlight border</h2>
</div>
<Button
type="submit"
variant="darkCTA"
className="pointer-events-none mt-4 flex max-w-[100px] animate-pulse cursor-not-allowed select-none items-center justify-center">
Loading
</Button>
</div>
<div className="flex w-1/2 flex-col items-center justify-center gap-4 bg-slate-200 px-6 py-5">
<h3 className="text-slate-500">Preview</h3>
<div className={cn("flex flex-col gap-4 rounded-lg border-2 bg-white p-5")}>
<h3 className="text-sm font-semibold text-slate-800">How easy was it for you to do this?</h3>
<div className="flex rounded-2xl border border-slate-400">
{[1, 2, 3, 4, 5].map((num) => (
<div key={num} className="border-r border-slate-400 px-6 py-5 last:border-r-0">
<span className="text-sm font-medium">{num}</span>
</div>
))}
</div>
</div>
</div>
</div>
</SettingsCard>
<SettingsCard
title="Formbricks Signature"
description="We love your support but understand if you toggle it off.">
<div className="w-full items-center">
<div className="pointer-events-none flex cursor-not-allowed select-none items-center space-x-2">
<Switch id="signature" checked={false} />
<Label htmlFor="signature">Show &apos;Powered by Formbricks&apos; Signature</Label>
</div>
</div>
</SettingsCard>
</div>
);
}

View File

@@ -1,23 +1,37 @@
export const revalidate = REVALIDATION_INTERVAL;
import { getProductByEnvironmentId } from "@formbricks/lib/services/product";
import { REVALIDATION_INTERVAL } from "@formbricks/lib/constants";
import SettingsCard from "../SettingsCard";
import SettingsTitle from "../SettingsTitle";
import { EditBrandColor, EditPlacement, EditFormbricksSignature } from "./editLookAndFeel";
import { EditFormbricksSignature } from "./EditSignature";
import { EditBrandColor } from "./EditBrandColor";
import { EditPlacement } from "./EditPlacement";
import { EditHighlightBorder } from "./EditHighlightBorder";
export default function ProfileSettingsPage({ params }: { params: { environmentId: string } }) {
export default async function ProfileSettingsPage({ params }: { params: { environmentId: string } }) {
const product = await getProductByEnvironmentId(params.environmentId);
return (
<div>
<SettingsTitle title="Look & Feel" />
<SettingsCard title="Brand Color" description="Match the surveys with your user interface.">
<EditBrandColor environmentId={params.environmentId} />
<EditBrandColor product={product} />
</SettingsCard>
<SettingsCard
title="In-app Survey Placement"
description="Change where surveys will be shown in your web app.">
<EditPlacement environmentId={params.environmentId} />
<EditPlacement product={product} />
</SettingsCard>
<SettingsCard
noPadding
title="Highlight Border"
description="Make sure your users notice the survey you display">
<EditHighlightBorder product={product} />
</SettingsCard>
<SettingsCard
title="Formbricks Signature"
description="We love your support but understand if you toggle it off.">
<EditFormbricksSignature environmentId={params.environmentId} />
<EditFormbricksSignature product={product} />
</SettingsCard>
</div>
);

View File

@@ -0,0 +1,136 @@
"use client";
import toast from "react-hot-toast";
import DeleteDialog from "@/components/shared/DeleteDialog";
import LoadingSpinner from "@/components/shared/LoadingSpinner";
import { useState, Dispatch, SetStateAction } from "react";
import { useRouter } from "next/navigation";
import { useMembers } from "@/lib/members";
import { useProfile } from "@/lib/profile";
import { Button, ErrorComponent, Input } from "@formbricks/ui";
import { useTeam, deleteTeam } from "@/lib/teams/teams";
import { useMemberships } from "@/lib/memberships";
export default function DeleteTeam({ environmentId }) {
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const router = useRouter();
const { profile } = useProfile();
const { memberships } = useMemberships();
const { team } = useMembers(environmentId);
const { team: teamData, isLoadingTeam, isErrorTeam } = useTeam(environmentId);
const availableTeams = memberships?.length;
const role = team?.members?.filter((member) => member?.userId === profile?.id)[0]?.role;
const isUserOwner = role === "owner";
const isDeleteDisabled = availableTeams <= 1 || !isUserOwner;
if (isLoadingTeam) {
return <LoadingSpinner />;
}
if (isErrorTeam) {
return <ErrorComponent />;
}
const handleDeleteTeam = async () => {
setIsDeleting(true);
const deleteTeamRes = await deleteTeam(environmentId);
setIsDeleteDialogOpen(false);
setIsDeleting(false);
if (deleteTeamRes?.deletedTeam?.id?.length > 0) {
toast.success("Team deleted successfully.");
router.push("/");
} else if (deleteTeamRes?.message?.length > 0) {
toast.error(deleteTeamRes.message);
} else {
toast.error("Error deleting team. Please try again.");
}
};
return (
<div>
{!isDeleteDisabled && (
<div>
<p className="text-sm text-slate-900">
This action cannot be undone. If it&apos;s gone, it&apos;s gone.
</p>
<Button
disabled={isDeleteDisabled}
variant="warn"
className={`mt-4 ${isDeleteDisabled ? "ring-grey-500 ring-1 ring-offset-1" : ""}`}
onClick={() => setIsDeleteDialogOpen(true)}>
Delete
</Button>
</div>
)}
{isDeleteDisabled && (
<p className="text-sm text-red-700">
{!isUserOwner
? "Only Owner can delete the team."
: "This is your only team, it cannot be deleted. Create a new team first."}
</p>
)}
<DeleteTeamModal
open={isDeleteDialogOpen}
setOpen={setIsDeleteDialogOpen}
teamData={teamData}
deleteTeam={handleDeleteTeam}
isDeleting={isDeleting}
/>
</div>
);
}
interface DeleteTeamModalProps {
open: boolean;
setOpen: Dispatch<SetStateAction<boolean>>;
teamData: { name: string; id: string; plan: string };
deleteTeam: () => void;
isDeleting?: boolean;
}
function DeleteTeamModal({ setOpen, open, teamData, deleteTeam, isDeleting }: DeleteTeamModalProps) {
const [inputValue, setInputValue] = useState("");
const handleInputChange = (e) => {
setInputValue(e.target.value);
};
return (
<DeleteDialog
open={open}
setOpen={setOpen}
deleteWhat="team"
onDelete={deleteTeam}
text="Before you proceed with deleting this team, please be aware of the following consequences:"
disabled={inputValue !== teamData?.name}
isDeleting={isDeleting}>
<div className="py-5">
<ul className="list-disc pb-6 pl-6">
<li>
Permanent removal of all <b>products linked to this team</b>. This includes all surveys,
responses, user actions and attributes associated with these products.
</li>
<li>This action cannot be undone. If it&apos;s gone, it&apos;s gone.</li>
</ul>
<form>
<label htmlFor="deleteTeamConfirmation">
Please enter <b>{teamData?.name}</b> in the following field to confirm the definitive deletion of
this team:
</label>
<Input
value={inputValue}
onChange={handleInputChange}
placeholder={teamData?.name}
className="mt-5"
type="text"
id="deleteTeamConfirmation"
name="deleteTeamConfirmation"
/>
</form>
</div>
</DeleteDialog>
);
}

View File

@@ -1,6 +1,7 @@
"use client";
import ShareInviteModal from "@/app/(app)/environments/[environmentId]/settings/members/ShareInviteModal";
import TransferOwnershipModal from "@/app/(app)/environments/[environmentId]/settings/members/TransferOwnershipModal";
import DeleteDialog from "@/components/shared/DeleteDialog";
import LoadingSpinner from "@/components/shared/LoadingSpinner";
import CreateTeamModal from "@/components/team/CreateTeamModal";
@@ -11,6 +12,7 @@ import {
removeMember,
resendInvite,
shareInvite,
transferOwnership,
updateInviteeRole,
updateMemberRole,
useMembers,
@@ -36,6 +38,9 @@ import { PaperAirplaneIcon, ShareIcon, TrashIcon } from "@heroicons/react/24/out
import { useState } from "react";
import toast from "react-hot-toast";
import AddMemberModal from "./AddMemberModal";
import { useRouter } from "next/navigation";
import { useMemberships } from "@/lib/memberships";
import CustomDialog from "@/components/shared/CustomDialog";
type EditMembershipsProps = {
environmentId: string;
@@ -46,13 +51,16 @@ interface Role {
memberRole: MembershipRole;
teamId: string;
memberId: string;
memberName: string;
environmentId: string;
userId: string;
memberAccepted: boolean;
inviteId: string;
currentUserRole: string;
}
enum MembershipRole {
Owner = "owner",
Admin = "admin",
Editor = "editor",
Developer = "developer",
@@ -64,13 +72,16 @@ function RoleElement({
memberRole,
teamId,
memberId,
memberName,
environmentId,
userId,
memberAccepted,
inviteId,
currentUserRole,
}: Role) {
const { mutateTeam } = useMembers(environmentId);
const [loading, setLoading] = useState(false);
const [isTransferOwnershipModalOpen, setTransferOwnershipModalOpen] = useState(false);
const disableRole =
memberRole && memberId && userId
? memberRole === ("owner" as MembershipRole) || memberId === userId
@@ -87,34 +98,71 @@ function RoleElement({
mutateTeam();
};
const handleOwnershipTransfer = async () => {
setLoading(true);
const isTransfered = await transferOwnership(teamId, memberId);
if (isTransfered) {
toast.success("Ownership transferred successfully");
} else {
toast.error("Something went wrong");
}
setTransferOwnershipModalOpen(false);
setLoading(false);
mutateTeam();
};
const handleRoleChange = (role: string) => {
if (role === "owner") {
setTransferOwnershipModalOpen(true);
} else {
handleMemberRoleUpdate(role);
}
};
const getMembershipRoles = () => {
if (currentUserRole === "owner" && memberAccepted) {
return Object.keys(MembershipRole);
}
return Object.keys(MembershipRole).filter((role) => role !== "Owner");
};
if (isAdminOrOwner) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
disabled={disableRole}
variant="secondary"
className="flex items-center gap-1 p-1.5 text-xs"
loading={loading}
size="sm">
<span className="ml-1">{capitalizeFirstLetter(memberRole)}</span>
<ChevronDownIcon className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
{!disableRole && (
<DropdownMenuContent>
<DropdownMenuRadioGroup
value={capitalizeFirstLetter(memberRole)}
onValueChange={(value) => handleMemberRoleUpdate(value.toLowerCase())}>
{Object.keys(MembershipRole).map((role) => (
<DropdownMenuRadioItem key={role} value={role}>
{capitalizeFirstLetter(role)}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
)}
</DropdownMenu>
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
disabled={disableRole}
variant="secondary"
className="flex items-center gap-1 p-1.5 text-xs"
loading={loading}
size="sm">
<span className="ml-1">{capitalizeFirstLetter(memberRole)}</span>
<ChevronDownIcon className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
{!disableRole && (
<DropdownMenuContent>
<DropdownMenuRadioGroup
value={capitalizeFirstLetter(memberRole)}
onValueChange={(value) => handleRoleChange(value.toLowerCase())}>
{getMembershipRoles().map((role) => (
<DropdownMenuRadioItem key={role} value={role}>
{capitalizeFirstLetter(role)}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
)}
</DropdownMenu>
<TransferOwnershipModal
open={isTransferOwnershipModalOpen}
setOpen={setTransferOwnershipModalOpen}
memberName={memberName}
onSubmit={handleOwnershipTransfer}
isLoading={loading}
/>
</>
);
}
@@ -124,18 +172,26 @@ function RoleElement({
export function EditMemberships({ environmentId }: EditMembershipsProps) {
const { team, isErrorTeam, isLoadingTeam, mutateTeam } = useMembers(environmentId);
const [loading, setLoading] = useState(false);
const [isAddMemberModalOpen, setAddMemberModalOpen] = useState(false);
const [isDeleteMemberModalOpen, setDeleteMemberModalOpen] = useState(false);
const [isCreateTeamModalOpen, setCreateTeamModalOpen] = useState(false);
const [showShareInviteModal, setShowShareInviteModal] = useState(false);
const [isLeaveTeamModalOpen, setLeaveTeamModalOpen] = useState(false);
const [shareInviteToken, setShareInviteToken] = useState<string>("");
const [activeMember, setActiveMember] = useState({} as any);
const { profile } = useProfile();
const { memberships } = useMemberships();
const router = useRouter();
const role = team?.members?.filter((member) => member?.userId === profile?.id)[0]?.role;
const isAdminOrOwner = role === "admin" || role === "owner";
const availableTeams = memberships?.length;
const isLeaveTeamDisabled = availableTeams <= 1;
const handleOpenDeleteMemberModal = (e, member) => {
e.preventDefault();
setActiveMember(member);
@@ -194,9 +250,27 @@ export function EditMemberships({ environmentId }: EditMembershipsProps) {
return now > expiresAt;
};
const handleLeaveTeam = async () => {
setLoading(true);
const result = await removeMember(team.teamId, profile?.id);
setLeaveTeamModalOpen(false);
setLoading(false);
if (!result) {
toast.error("Something went wrong");
} else {
toast.success("You left the team successfully");
router.push("/");
}
};
return (
<>
<div className="mb-6 text-right">
{role !== "owner" && (
<Button variant="minimal" className="mr-2" onClick={() => setLeaveTeamModalOpen(true)}>
Leave Team
</Button>
)}
<Button
variant="secondary"
className="mr-2"
@@ -242,11 +316,13 @@ export function EditMemberships({ environmentId }: EditMembershipsProps) {
isAdminOrOwner={isAdminOrOwner}
memberRole={member.role}
memberId={member.userId}
memberName={member.name}
teamId={team.teamId}
environmentId={environmentId}
userId={profile?.id}
memberAccepted={member.accepted}
inviteId={member?.inviteId}
currentUserRole={role}
/>
</div>
<div className="col-span-5 flex items-center justify-end gap-x-4 pr-4">
@@ -310,6 +386,23 @@ export function EditMemberships({ environmentId }: EditMembershipsProps) {
deleteWhat={activeMember.name + " from your team"}
onDelete={handleDeleteMember}
/>
<CustomDialog
open={isLeaveTeamModalOpen}
setOpen={setLeaveTeamModalOpen}
title="Are you sure?"
text="You wil leave this team and loose access to all surveys and responses. You can only rejoin if you are invited again."
onOk={handleLeaveTeam}
okBtnText="Yes, leave team"
disabled={isLeaveTeamDisabled}
isLoading={loading}>
{isLeaveTeamDisabled && (
<p className="mt-2 text-sm text-red-700">
You cannot leave this team as it is your only team. Create a new team first.
</p>
)}
</CustomDialog>
{showShareInviteModal && (
<ShareInviteModal
inviteToken={shareInviteToken}

View File

@@ -5,18 +5,27 @@ import { useTeamMutation } from "@/lib/teams/mutateTeams";
import { useTeam } from "@/lib/teams/teams";
import { Button, ErrorComponent, Input, Label } from "@formbricks/ui";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { useForm, useWatch } from "react-hook-form";
import toast from "react-hot-toast";
export default function EditTeamName({ environmentId }) {
const { team, isLoadingTeam, isErrorTeam, mutateTeam } = useTeam(environmentId);
const { register, handleSubmit } = useForm();
const { register, control, handleSubmit, setValue } = useForm();
const [teamId, setTeamId] = useState("");
const teamName = useWatch({
control,
name: "name",
});
const isTeamNameInputEmpty = !teamName?.trim();
const currentTeamName = teamName?.trim().toLowerCase() ?? "";
const previousTeamName = team?.name?.trim().toLowerCase() ?? "";
useEffect(() => {
if (team && team.id !== "") {
setTeamId(team.id);
}
setValue("name", team?.name ?? "");
}, [team]);
const { isMutatingTeam, triggerTeamMutate } = useTeamMutation(teamId);
@@ -42,9 +51,20 @@ export default function EditTeamName({ environmentId }) {
});
})}>
<Label htmlFor="teamname">Team Name</Label>
<Input type="text" id="teamname" defaultValue={team.name} {...register("name")} />
<Input
type="text"
id="teamname"
defaultValue={team?.name ?? ""}
{...register("name")}
className={isTeamNameInputEmpty ? "border-red-300 focus:border-red-300" : ""}
/>
<Button type="submit" className="mt-4" variant="darkCTA" loading={isMutatingTeam}>
<Button
type="submit"
className="mt-4"
variant="darkCTA"
loading={isMutatingTeam}
disabled={isTeamNameInputEmpty || currentTeamName === previousTeamName}>
Update
</Button>
</form>

View File

@@ -0,0 +1,61 @@
import CustomDialog from "@/components/shared/CustomDialog";
import { Input } from "@formbricks/ui";
import { Dispatch, SetStateAction, useState } from "react";
interface TransferOwnershipModalProps {
open: boolean;
setOpen: Dispatch<SetStateAction<boolean>>;
memberName: string;
onSubmit: () => void;
isLoading?: boolean;
}
export default function TransferOwnershipModal({
setOpen,
open,
memberName,
onSubmit,
isLoading,
}: TransferOwnershipModalProps) {
const [inputValue, setInputValue] = useState("");
const handleInputChange = (e) => {
setInputValue(e.target.value);
};
return (
<CustomDialog
open={open}
setOpen={setOpen}
onOk={onSubmit}
okBtnText="Transfer ownership"
title="There can only be ONE owner! Are you sure?"
cancelBtnText="CANCEL"
disabled={inputValue !== memberName}
isLoading={isLoading}>
<div className="py-5">
<ul className="list-disc pb-6 pl-6">
<li>
There can only be one owner of each team. If you transfer your ownership to <b>{memberName}</b>,
you will lose all of your ownership rights.
</li>
<li>When you transfer the ownership, you will remain an Admin of the team.</li>
</ul>
<form>
<label htmlFor="transferOwnershipConfirmation">
Type in <b>{memberName}</b> to confirm:
</label>
<Input
value={inputValue}
onChange={handleInputChange}
placeholder={memberName}
className="mt-5"
type="text"
id="transferOwnershipConfirmation"
name="transferOwnershipConfirmation"
/>
</form>
</div>
</CustomDialog>
);
}

View File

@@ -2,6 +2,7 @@ import SettingsCard from "../SettingsCard";
import SettingsTitle from "../SettingsTitle";
import { EditMemberships } from "./EditMemberships";
import EditTeamName from "./EditTeamName";
import DeleteTeam from "./DeleteTeam";
export default function MembersSettingsPage({ params }) {
return (
@@ -13,6 +14,11 @@ export default function MembersSettingsPage({ params }) {
<SettingsCard title="Team Name" description="Give your team a descriptive name.">
<EditTeamName environmentId={params.environmentId} />
</SettingsCard>
<SettingsCard
title="Delete Team"
description="Delete team with all its products including all surveys, responses, people, actions and attributes">
<DeleteTeam environmentId={params.environmentId} />
</SettingsCard>
</div>
);
}

View File

@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { useEffect, useState } from "react";
import { useForm, useWatch } from "react-hook-form";
import toast from "react-hot-toast";
import { useRouter } from "next/navigation";
@@ -22,7 +22,19 @@ export function EditProductName({ environmentId }) {
const { isMutatingProduct, triggerProductMutate } = useProductMutation(environmentId);
const { mutateEnvironment } = useEnvironment(environmentId);
const { register, handleSubmit } = useForm();
const { register, handleSubmit, control, setValue } = useForm();
const productName = useWatch({
control,
name: "name",
});
const isProductNameInputEmpty = !productName?.trim();
const currentProductName = productName?.trim().toLowerCase() ?? "";
const previousProductName = product?.name?.trim().toLowerCase() ?? "";
useEffect(() => {
setValue("name", product?.name ?? "");
}, [product?.name]);
if (isLoadingProduct) {
return <LoadingSpinner />;
@@ -45,9 +57,20 @@ export function EditProductName({ environmentId }) {
});
})}>
<Label htmlFor="fullname">What&apos;s your product called?</Label>
<Input type="text" id="fullname" defaultValue={product.name} {...register("name")} />
<Input
type="text"
id="fullname"
defaultValue={product.name}
{...register("name")}
className={isProductNameInputEmpty ? "border-red-300 focus:border-red-300" : ""}
/>
<Button type="submit" variant="darkCTA" className="mt-4" loading={isMutatingProduct}>
<Button
type="submit"
variant="darkCTA"
className="mt-4"
loading={isMutatingProduct}
disabled={isProductNameInputEmpty || currentProductName === previousProductName}>
Update
</Button>
</form>
@@ -102,6 +125,7 @@ export function DeleteProduct({ environmentId }) {
const router = useRouter();
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [deletingProduct, setDeletingProduct] = useState(false);
const { profile } = useProfile();
const { team } = useMembers(environmentId);
@@ -126,7 +150,9 @@ export function DeleteProduct({ environmentId }) {
setIsDeleteDialogOpen(false);
return;
}
setDeletingProduct(true);
const deleteProductRes = await deleteProduct(environmentId);
setDeletingProduct(false);
if (deleteProductRes?.id?.length > 0) {
toast.success("Product deleted successfully.");
@@ -168,6 +194,7 @@ export function DeleteProduct({ environmentId }) {
deleteWhat="Product"
open={isDeleteDialogOpen}
setOpen={setIsDeleteDialogOpen}
isDeleting={deletingProduct}
onDelete={handleDeleteProduct}
text={`Are you sure you want to delete "${truncate(
product?.name,

View File

@@ -1,58 +1,16 @@
"use client";
import DeleteDialog from "@/components/shared/DeleteDialog";
import LoadingSpinner from "@/components/shared/LoadingSpinner";
import AvatarPlaceholder from "@/images/avatar-placeholder.png";
import { formbricksLogout } from "@/lib/formbricks";
import { useProfileMutation } from "@/lib/profile/mutateProfile";
import { useProfile } from "@/lib/profile/profile";
import { deleteProfile } from "@/lib/users/users";
import { Button, ErrorComponent, Input, Label, ProfileAvatar } from "@formbricks/ui";
import { Button, Input, ProfileAvatar } from "@formbricks/ui";
import { Session } from "next-auth";
import { signOut } from "next-auth/react";
import Image from "next/image";
import { Dispatch, SetStateAction, useState } from "react";
import { useForm } from "react-hook-form";
import toast from "react-hot-toast";
export function EditName() {
const { register, handleSubmit } = useForm();
const { profile, isLoadingProfile, isErrorProfile } = useProfile();
const { triggerProfileMutate, isMutatingProfile } = useProfileMutation();
if (isLoadingProfile) {
return <LoadingSpinner />;
}
if (isErrorProfile) {
return <ErrorComponent />;
}
return (
<form
className="w-full max-w-sm items-center"
onSubmit={handleSubmit((data) => {
triggerProfileMutate(data)
.then(() => {
toast.success("Your name was updated successfully.");
})
.catch((error) => {
toast.error(`Error: ${error.message}`);
});
})}>
<Label htmlFor="fullname">Full Name</Label>
<Input type="text" id="fullname" defaultValue={profile.name} {...register("name")} />
<div className="mt-4">
<Label htmlFor="email">Email</Label>
<Input type="email" id="fullname" defaultValue={profile.email} disabled />
</div>
<Button type="submit" variant="darkCTA" className="mt-4" loading={isMutatingProfile}>
Update
</Button>
</form>
);
}
import { profileDeleteAction } from "./actions";
import { TProfile } from "@formbricks/types/v1/profile";
export function EditAvatar({ session }) {
return (
@@ -76,13 +34,14 @@ export function EditAvatar({ session }) {
);
}
interface DeleteAccounModaltProps {
interface DeleteAccountModalProps {
open: boolean;
setOpen: Dispatch<SetStateAction<boolean>>;
session: Session;
profile: TProfile;
}
function DeleteAccountModal({ setOpen, open, session }: DeleteAccounModaltProps) {
function DeleteAccountModal({ setOpen, open, session, profile }: DeleteAccountModalProps) {
const [deleting, setDeleting] = useState(false);
const [inputValue, setInputValue] = useState("");
@@ -93,7 +52,7 @@ function DeleteAccountModal({ setOpen, open, session }: DeleteAccounModaltProps)
const deleteAccount = async () => {
try {
setDeleting(true);
await deleteProfile();
await profileDeleteAction(profile.id);
await signOut();
await formbricksLogout();
} catch (error) {
@@ -146,7 +105,7 @@ function DeleteAccountModal({ setOpen, open, session }: DeleteAccounModaltProps)
);
}
export function DeleteAccount({ session }: { session: Session | null }) {
export function DeleteAccount({ session, profile }: { session: Session | null; profile: TProfile }) {
const [isModalOpen, setModalOpen] = useState(false);
if (!session) {
@@ -155,7 +114,7 @@ export function DeleteAccount({ session }: { session: Session | null }) {
return (
<div>
<DeleteAccountModal open={isModalOpen} setOpen={setModalOpen} session={session} />
<DeleteAccountModal open={isModalOpen} setOpen={setModalOpen} session={session} profile={profile} />
<p className="text-sm text-slate-700">
Delete your account with all personal data. <strong>This cannot be undone!</strong>
</p>

View File

@@ -0,0 +1,28 @@
"use client";
import AvatarPlaceholder from "@/images/avatar-placeholder.png";
import { Button, ProfileAvatar } from "@formbricks/ui";
import Image from "next/image";
import { Session } from "next-auth";
export function EditAvatar({ session }: { session: Session | null }) {
return (
<div>
{session?.user?.image ? (
<Image
src={AvatarPlaceholder}
width="100"
height="100"
className="h-24 w-24 rounded-full"
alt="Avatar placeholder"
/>
) : (
<ProfileAvatar userId={session!.user.id} />
)}
<Button className="mt-4" variant="darkCTA" disabled={true}>
Upload Image
</Button>
</div>
);
}

View File

@@ -0,0 +1,46 @@
"use client";
import { Button, Input, Label } from "@formbricks/ui";
import { useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { profileEditAction } from "./actions";
import { TProfile } from "@formbricks/types/v1/profile";
export function EditName({ profile }: { profile: TProfile }) {
const {
register,
handleSubmit,
formState: { isSubmitting },
} = useForm<{ name: string }>();
return (
<>
<form
className="w-full max-w-sm items-center"
onSubmit={handleSubmit(async (data) => {
try {
await profileEditAction(profile.id, data);
toast.success("Your name was updated successfully.");
} catch (error) {
toast.error(`Error: ${error.message}`);
}
})}>
<Label htmlFor="fullname">Full Name</Label>
<Input
type="text"
id="fullname"
defaultValue={profile.name ? profile.name : ""}
{...register("name")}
/>
<div className="mt-4">
<Label htmlFor="email">Email</Label>
<Input type="email" id="fullname" defaultValue={profile.email} disabled />
</div>
<Button type="submit" variant="darkCTA" className="mt-4" loading={isSubmitting}>
Update
</Button>
</form>
</>
);
}

View File

@@ -0,0 +1,12 @@
"use server";
import { updateProfile, deleteProfile } from "@formbricks/lib/services/profile";
import { TProfileUpdateInput } from "@formbricks/types/v1/profile";
export async function profileEditAction(userId: string, data: Partial<TProfileUpdateInput>) {
return await updateProfile(userId, data);
}
export async function profileDeleteAction(userId: string) {
return await deleteProfile(userId);
}

View File

@@ -0,0 +1,54 @@
function LoadingCard({ title, description, skeletonLines }) {
return (
<div className="my-4 rounded-lg border border-slate-200">
<div className="grid content-center rounded-lg bg-slate-100 px-6 py-5 text-left text-slate-900">
<h3 className="text-lg font-medium leading-6">{title}</h3>
<p className="mt-1 text-sm text-slate-500">{description}</p>
</div>
<div className="w-full">
<div className="rounded-lg px-6 py-5 hover:bg-slate-100">
{skeletonLines.map((line, index) => (
<div key={index} className="mt-4">
<div className={`animate-pulse rounded-full bg-gray-200 ${line.classes}`}></div>
</div>
))}
</div>
</div>
</div>
);
}
export default function Loading() {
const cards = [
{
title: "Personal Information",
description: "Update your personal information",
skeletonLines: [
{ classes: "h-4 w-28" },
{ classes: "h-6 w-64" },
{ classes: "h-4 w-28" },
{ classes: "h-6 w-64" },
{ classes: "h-8 w-24" },
],
},
{
title: "Avatar",
description: "Assist your team in identifying you on Formbricks.",
skeletonLines: [{ classes: "h-10 w-10" }, { classes: "h-8 w-24" }],
},
{
title: "Delete account",
description: "Delete your account with all of your personal information and data.",
skeletonLines: [{ classes: "h-4 w-60" }, { classes: "h-8 w-24" }],
},
];
return (
<div>
<h2 className="my-4 text-2xl font-medium leading-6 text-slate-800">Profile</h2>
{cards.map((card, index) => (
<LoadingCard key={index} {...card} />
))}
</div>
);
}

View File

@@ -1,25 +1,37 @@
export const revalidate = REVALIDATION_INTERVAL;
import { REVALIDATION_INTERVAL } from "@formbricks/lib/constants";
import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import SettingsCard from "../SettingsCard";
import SettingsTitle from "../SettingsTitle";
import { getServerSession } from "next-auth";
import { EditName, EditAvatar, DeleteAccount } from "./editProfile";
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { DeleteAccount } from "./DeleteAccount";
import { EditName } from "./EditName";
import { EditAvatar } from "./EditAvatar";
import { getProfile } from "@formbricks/lib/services/profile";
export default async function ProfileSettingsPage() {
const session = await getServerSession(authOptions);
const profile = session ? await getProfile(session.user.id) : null;
return (
<div>
<SettingsTitle title="Profile" />
<SettingsCard title="Personal Information" description="Update your personal information.">
<EditName />
</SettingsCard>
<SettingsCard title="Avatar" description="Assist your team in identifying you on Formbricks.">
<EditAvatar session={session} />
</SettingsCard>
<SettingsCard
title="Delete account"
description="Delete your account with all of your personal information and data.">
<DeleteAccount session={session} />
</SettingsCard>
</div>
<>
{profile && (
<div>
<SettingsTitle title="Profile" />
<SettingsCard title="Personal Information" description="Update your personal information.">
<EditName profile={profile} />
</SettingsCard>
<SettingsCard title="Avatar" description="Assist your team in identifying you on Formbricks.">
<EditAvatar session={session} />
</SettingsCard>
<SettingsCard
title="Delete account"
description="Delete your account with all of your personal information and data.">
<DeleteAccount session={session} profile={profile} />
</SettingsCard>
</div>
)}
</>
);
}

View File

@@ -4,12 +4,16 @@ import FormbricksSignature from "@/components/preview/FormbricksSignature";
import Modal from "@/components/preview/Modal";
import Progress from "@/components/preview/Progress";
import QuestionConditional from "@/components/preview/QuestionConditional";
import TabOption from "@/components/preview/TabOption";
import ThankYouCard from "@/components/preview/ThankYouCard";
import type { Logic, Question } from "@formbricks/types/questions";
import { Survey } from "@formbricks/types/surveys";
import { useEffect, useRef, useState } from "react";
import type { TProduct } from "@formbricks/types/v1/product";
import type { TEnvironment } from "@formbricks/types/v1/environment";
import type { TProduct } from "@formbricks/types/v1/product";
import { Button } from "@formbricks/ui";
import { ArrowPathRoundedSquareIcon } from "@heroicons/react/24/outline";
import { ComputerDesktopIcon, DevicePhoneMobileIcon } from "@heroicons/react/24/solid";
import { useEffect, useRef, useState } from "react";
interface PreviewSurveyProps {
setActiveQuestionId: (id: string | null) => void;
activeQuestionId?: string | null;
@@ -24,6 +28,77 @@ interface PreviewSurveyProps {
environment: TEnvironment;
}
function QuestionRenderer({
activeQuestionId,
lastActiveQuestionId,
questions,
brandColor,
thankYouCard,
gotoNextQuestion,
showBackButton,
goToPreviousQuestion,
storedResponseValue,
}) {
return (
<div>
{(activeQuestionId || lastActiveQuestionId) === "thank-you-card" ? (
<ThankYouCard
brandColor={brandColor}
headline={thankYouCard?.headline || "Thank you!"}
subheader={thankYouCard?.subheader || "We appreciate your feedback."}
/>
) : (
questions.map((question, idx) =>
(activeQuestionId || lastActiveQuestionId) === question.id ? (
<QuestionConditional
key={question.id}
question={question}
brandColor={brandColor}
lastQuestion={idx === questions.length - 1}
onSubmit={gotoNextQuestion}
storedResponseValue={storedResponseValue}
goToNextQuestion={gotoNextQuestion}
goToPreviousQuestion={showBackButton ? goToPreviousQuestion : undefined}
autoFocus={false}
/>
) : null
)
)}
</div>
);
}
function PreviewModalContent({
activeQuestionId,
lastActiveQuestionId,
questions,
brandColor,
thankYouCard,
gotoNextQuestion,
showBackButton,
goToPreviousQuestion,
storedResponseValue,
showFormbricksSignature,
}) {
return (
<div className="px-4 py-6 sm:p-6">
<QuestionRenderer
activeQuestionId={activeQuestionId}
lastActiveQuestionId={lastActiveQuestionId}
questions={questions}
brandColor={brandColor}
thankYouCard={thankYouCard}
gotoNextQuestion={gotoNextQuestion}
showBackButton={showBackButton}
goToPreviousQuestion={goToPreviousQuestion}
storedResponseValue={storedResponseValue}
/>
{showFormbricksSignature && <FormbricksSignature />}
</div>
);
}
export default function PreviewSurvey({
setActiveQuestionId,
activeQuestionId,
@@ -44,8 +119,9 @@ export default function PreviewSurvey({
const [finished, setFinished] = useState(false);
const [storedResponseValue, setStoredResponseValue] = useState<any>();
const [storedResponse, setStoredResponse] = useState<Record<string, any>>({});
const [previewMode, setPreviewMode] = useState("desktop");
const showBackButton = progress !== 0 && !finished;
const ContentRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (product) {
@@ -106,6 +182,13 @@ export default function PreviewSurvey({
}, [autoClose]);
useEffect(() => {
if (ContentRef.current) {
// scroll to top whenever question changes
ContentRef.current.scrollTop = 0;
}
if (activeQuestionId !== "end") {
setFinished(false);
}
if (activeQuestionId) {
setLastActiveQuestionId(activeQuestionId);
setProgress(calculateProgress(questions, activeQuestionId));
@@ -138,7 +221,9 @@ export default function PreviewSurvey({
switch (logic.condition) {
case "equals":
return (
(Array.isArray(responseValue) && responseValue.length === 1 && responseValue.includes(logic.value)) ||
(Array.isArray(responseValue) &&
responseValue.length === 1 &&
responseValue.includes(logic.value)) ||
responseValue.toString() === logic.value
);
case "notEquals":
@@ -241,6 +326,12 @@ export default function PreviewSurvey({
setActiveQuestionId(previousQuestionId);
}
function resetQuestionProgress() {
setProgress(0);
setActiveQuestionId(questions[0].id);
setStoredResponse({});
}
useEffect(() => {
if (environment && environment.widgetSetupCompleted) {
setWidgetSetupCompleted(true);
@@ -258,93 +349,159 @@ export default function PreviewSurvey({
}
return (
<div className="flex h-full w-5/6 flex-1 flex-col rounded-lg border border-slate-300 bg-slate-200 ">
<div className="flex h-8 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>
<p>
<span className="ml-4 font-mono text-sm text-slate-400">
{previewType === "modal" ? "Your web app" : "Preview"}
</span>
</p>
</div>
{previewType === "modal" ? (
<Modal isOpen={isModalOpen} placement={product.placement}>
{!countdownStop && autoClose !== null && autoClose > 0 && (
<Progress progress={countdownProgress} brandColor={brandColor} />
)}
<div
onClick={() => handleStopCountdown()}
onMouseOver={() => handleStopCountdown()}
className="px-4 py-6 sm:p-6">
{(activeQuestionId || lastActiveQuestionId) === "thank-you-card" ? (
<ThankYouCard
brandColor={brandColor}
headline={thankYouCard?.headline || "Thank you!"}
subheader={thankYouCard?.subheader || "We appreciate your feedback."}
/>
) : (
questions.map((question, idx) =>
(activeQuestionId || lastActiveQuestionId) === question.id ? (
<QuestionConditional
key={question.id}
question={question}
<div className="flex h-full w-full flex-col items-center justify-items-center">
<div className="relative flex h-[95%] max-h-[95%] w-5/6 items-center justify-center rounded-lg border border-slate-300 bg-slate-200">
{previewMode === "mobile" && (
<>
<div className="absolute right-0 top-0 m-2">
<ResetProgressButton resetQuestionProgress={resetQuestionProgress} />
</div>
<div className="relative h-[90%] max-h-[40rem] w-80 overflow-hidden rounded-[3rem] border-8 border-slate-500 bg-slate-400">
{/* below element is use to create notch for the mobile device mockup */}
<div className="absolute left-1/2 right-1/2 top-0 z-20 h-4 w-1/2 -translate-x-1/2 transform rounded-b-md bg-slate-500"></div>
{previewType === "modal" ? (
<Modal
isOpen={isModalOpen}
placement={product.placement}
highlightBorderColor={product.highlightBorderColor}
previewMode="mobile">
{!countdownStop && autoClose !== null && autoClose > 0 && (
<Progress progress={countdownProgress} brandColor={brandColor} />
)}
<PreviewModalContent
activeQuestionId={activeQuestionId}
lastActiveQuestionId={lastActiveQuestionId}
questions={questions}
brandColor={brandColor}
lastQuestion={idx === questions.length - 1}
onSubmit={gotoNextQuestion}
thankYouCard={thankYouCard}
gotoNextQuestion={gotoNextQuestion}
showBackButton={showBackButton}
goToPreviousQuestion={goToPreviousQuestion}
storedResponseValue={storedResponseValue}
goToNextQuestion={gotoNextQuestion}
goToPreviousQuestion={showBackButton ? goToPreviousQuestion : undefined}
autoFocus={false}
showFormbricksSignature={showFormbricksSignature}
/>
) : null
)
)}
{showFormbricksSignature && <FormbricksSignature />}
</div>
<Progress progress={progress} brandColor={brandColor} />
</Modal>
) : (
<div className="flex flex-grow flex-col overflow-y-auto">
<div className="flex w-full flex-grow flex-col items-center justify-center bg-white py-6">
<div className="w-full max-w-md">
{(activeQuestionId || lastActiveQuestionId) === "thank-you-card" ? (
<ThankYouCard
brandColor={brandColor}
headline={thankYouCard?.headline || "Thank you!"}
subheader={thankYouCard?.subheader || "We appreciate your feedback."}
/>
<Progress progress={progress} brandColor={brandColor} />
</Modal>
) : (
questions.map((question, idx) =>
(activeQuestionId || lastActiveQuestionId) === question.id ? (
<QuestionConditional
key={question.id}
question={question}
brandColor={brandColor}
lastQuestion={idx === questions.length - 1}
onSubmit={gotoNextQuestion}
storedResponseValue={storedResponseValue}
goToNextQuestion={gotoNextQuestion}
goToPreviousQuestion={showBackButton ? goToPreviousQuestion : undefined}
autoFocus={false}
/>
) : null
)
<div
className="absolute top-0 z-10 flex h-full w-full flex-grow flex-col overflow-y-auto"
ref={ContentRef}>
<div className="flex w-full flex-grow flex-col items-center justify-center bg-white py-6">
<div className="w-full max-w-md px-4">
<QuestionRenderer
activeQuestionId={activeQuestionId}
lastActiveQuestionId={lastActiveQuestionId}
questions={questions}
brandColor={brandColor}
thankYouCard={thankYouCard}
gotoNextQuestion={gotoNextQuestion}
showBackButton={showBackButton}
goToPreviousQuestion={goToPreviousQuestion}
storedResponseValue={storedResponseValue}
/>
</div>
</div>
<div className="z-10 w-full rounded-b-lg bg-white">
<div className="mx-auto max-w-md space-y-6 p-6 pt-4">
<Progress progress={progress} brandColor={brandColor} />
{showFormbricksSignature && <FormbricksSignature />}
</div>
</div>
</div>
)}
</div>
</div>
<div className="z-10 w-full rounded-b-lg bg-white">
<div className="mx-auto max-w-md space-y-6 p-6 pt-4">
<Progress progress={progress} brandColor={brandColor} />
{showFormbricksSignature && <FormbricksSignature />}
</>
)}
{previewMode === "desktop" && (
<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>
<p className="ml-4 flex w-full justify-between font-mono text-sm text-slate-400">
{previewType === "modal" ? "Your web app" : "Preview"}
<ResetProgressButton resetQuestionProgress={resetQuestionProgress} />
</p>
</div>
{previewType === "modal" ? (
<Modal
isOpen={isModalOpen}
placement={product.placement}
highlightBorderColor={product.highlightBorderColor}
previewMode="desktop">
{!countdownStop && autoClose !== null && autoClose > 0 && (
<Progress progress={countdownProgress} brandColor={brandColor} />
)}
<PreviewModalContent
activeQuestionId={activeQuestionId}
lastActiveQuestionId={lastActiveQuestionId}
questions={questions}
brandColor={brandColor}
thankYouCard={thankYouCard}
gotoNextQuestion={gotoNextQuestion}
showBackButton={showBackButton}
goToPreviousQuestion={goToPreviousQuestion}
storedResponseValue={storedResponseValue}
showFormbricksSignature={showFormbricksSignature}
/>
<Progress progress={progress} brandColor={brandColor} />
</Modal>
) : (
<div className="flex flex-grow flex-col overflow-y-auto" ref={ContentRef}>
<div className="flex w-full flex-grow flex-col items-center justify-center bg-white py-6">
<div className="w-full max-w-md">
<QuestionRenderer
activeQuestionId={activeQuestionId}
lastActiveQuestionId={lastActiveQuestionId}
questions={questions}
brandColor={brandColor}
thankYouCard={thankYouCard}
gotoNextQuestion={gotoNextQuestion}
showBackButton={showBackButton}
goToPreviousQuestion={goToPreviousQuestion}
storedResponseValue={storedResponseValue}
/>
</div>
</div>
<div className="z-10 w-full rounded-b-lg bg-white">
<div className="mx-auto max-w-md space-y-6 p-6 pt-4">
<Progress progress={progress} brandColor={brandColor} />
{showFormbricksSignature && <FormbricksSignature />}
</div>
</div>
</div>
)}
</div>
</div>
)}
)}
</div>
{/* for toggling between mobile and desktop mode */}
<div className="mt-2 flex rounded-full border-2 border-slate-300 p-1">
<TabOption
active={previewMode === "mobile"}
icon={<DevicePhoneMobileIcon className="mx-4 my-2 h-4 w-4 text-slate-700" />}
onClick={() => setPreviewMode("mobile")}
/>
<TabOption
active={previewMode === "desktop"}
icon={<ComputerDesktopIcon className="mx-4 my-2 h-4 w-4 text-slate-700" />}
onClick={() => setPreviewMode("desktop")}
/>
</div>
</div>
);
}
function ResetProgressButton({ resetQuestionProgress }) {
return (
<Button
variant="minimal"
className="py-0.2 bg-white mr-2 px-2 text-sm text-slate-500 font-sans"
onClick={resetQuestionProgress}>
Restart
<ArrowPathRoundedSquareIcon className="ml-2 h-4 w-4" />
</Button>
);
}

View File

@@ -1,14 +1,14 @@
import SurveyDropDownMenu from "@/app/(app)/environments/[environmentId]/surveys/SurveyDropDownMenu";
import SurveyStarter from "@/app/(app)/environments/[environmentId]/surveys/SurveyStarter";
import SurveyStatusIndicator from "@/components/shared/SurveyStatusIndicator";
import { getEnvironment, getEnvironments } from "@formbricks/lib/services/environment";
import { getProductByEnvironmentId } from "@formbricks/lib/services/product";
import { getSurveysWithAnalytics } from "@formbricks/lib/services/survey";
import type { TEnvironment } from "@formbricks/types/v1/environment";
import type { TSurveyWithAnalytics } from "@formbricks/types/v1/surveys";
import { Badge } from "@formbricks/ui";
import { ComputerDesktopIcon, LinkIcon, PlusIcon } from "@heroicons/react/24/solid";
import Link from "next/link";
import SurveyDropDownMenu from "@/app/(app)/environments/[environmentId]/surveys/SurveyDropDownMenu";
import SurveyStarter from "@/app/(app)/environments/[environmentId]/surveys/SurveyStarter";
import { getProductByEnvironmentId } from "@formbricks/lib/services/product";
import { getEnvironment, getEnvironments } from "@formbricks/lib/services/environment";
import { getSurveysWithAnalytics } from "@formbricks/lib/services/survey";
import type { TSurveyWithAnalytics } from "@formbricks/types/v1/surveys";
import type { TEnvironment } from "@formbricks/types/v1/environment";
export default async function SurveysList({ environmentId }: { environmentId: string }) {
const product = await getProductByEnvironmentId(environmentId);
@@ -23,7 +23,7 @@ export default async function SurveysList({ environmentId }: { environmentId: st
return (
<>
<ul className="grid grid-cols-2 place-content-stretch gap-6 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-5 ">
<ul className="grid place-content-stretch gap-6 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-5 ">
<Link href={`/environments/${environmentId}/surveys/templates`}>
<li className="col-span-1 h-56">
<div className="delay-50 flex h-full items-center justify-center overflow-hidden rounded-md bg-gradient-to-br from-slate-900 to-slate-800 font-light text-white shadow transition ease-in-out hover:scale-105 hover:from-slate-800 hover:to-slate-700">

View File

@@ -22,9 +22,12 @@ export default function SurveyStarter({
const router = useRouter();
const newSurveyFromTemplate = async (template: Template) => {
setIsCreateSurveyLoading(true);
const surveyType = environment?.widgetSetupCompleted ? "web" : "link";
const autoComplete = surveyType === "web" ? 50 : null;
const augmentedTemplate = {
...template.preset,
type: environment?.widgetSetupCompleted ? "web" : "link",
type: surveyType,
autoComplete,
};
try {
const survey = await createSurveyAction(environmentId, augmentedTemplate);

View File

@@ -13,7 +13,10 @@ export const getAnalysisData = async (surveyId: string, environmentId: string) =
if (!team) throw new Error(`Team not found for environment: ${environmentId}`);
if (survey.environmentId !== environmentId) throw new Error(`Survey not found: ${surveyId}`);
const limitReached =
IS_FORMBRICKS_CLOUD && team.plan === "free" && allResponses.length >= RESPONSES_LIMIT_FREE;
IS_FORMBRICKS_CLOUD &&
team.plan === "free" &&
survey.type === "web" &&
allResponses.length >= RESPONSES_LIMIT_FREE;
const responses = limitReached ? allResponses.slice(0, RESPONSES_LIMIT_FREE) : allResponses;
const responsesCount = allResponses.length;

View File

@@ -26,19 +26,21 @@ export default function CTASummary({ questionSummary }: CTASummaryProps) {
return (
<div className=" rounded-lg border border-slate-200 bg-slate-50 shadow-sm">
<div className="space-y-2 px-6 pb-5 pt-6">
<div className="space-y-2 px-4 pb-5 pt-6 md:px-6">
<div>
<h3 className="pb-1 text-xl font-semibold text-slate-900">{questionSummary.question.headline}</h3>
<h3 className="text-lg pb-1 font-semibold text-slate-900 md:text-xl">
{questionSummary.question.headline}
</h3>
</div>
<div className="flex space-x-2 font-semibold text-slate-600">
<div className="rounded-lg bg-slate-100 p-2 text-sm">Call-to-Action</div>
<div className=" flex items-center rounded-lg bg-slate-100 p-2 text-sm">
<div className="flex space-x-2 text-xs font-semibold text-slate-600 md:text-sm">
<div className="rounded-lg bg-slate-100 p-2 ">Call-to-Action</div>
<div className=" flex items-center rounded-lg bg-slate-100 p-2">
<InboxStackIcon className="mr-2 h-4 w-4 " />
{ctr.count} responses
</div>
</div>
</div>
<div className="space-y-5 rounded-b-lg bg-white px-6 pb-6 pt-4">
<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="text flex justify-between px-2 pb-2">
<div className="mr-8 flex space-x-1">
<p className="font-semibold text-slate-700">Clickthrough Rate (CTR)</p>

View File

@@ -34,19 +34,21 @@ export default function ConsentSummary({ questionSummary }: ConsentSummaryProps)
return (
<div className=" rounded-lg border border-slate-200 bg-slate-50 shadow-sm">
<div className="space-y-2 px-6 pb-5 pt-6">
<div className="space-y-2 px-4 pb-5 pt-6 md:px-6">
<div>
<h3 className="pb-1 text-xl font-semibold text-slate-900">{questionSummary.question.headline}</h3>
<h3 className="pb-1 text-lg font-semibold text-slate-900 md:text-xl">
{questionSummary.question.headline}
</h3>
</div>
<div className="flex space-x-2 font-semibold text-slate-600">
<div className="rounded-lg bg-slate-100 p-2 text-sm">Consent</div>
<div className=" flex items-center rounded-lg bg-slate-100 p-2 text-sm">
<div className="flex space-x-2 text-xs font-semibold text-slate-600 md:text-sm">
<div className="rounded-lg bg-slate-100 p-2">Consent</div>
<div className=" flex items-center rounded-lg bg-slate-100 p-2">
<InboxStackIcon className="mr-2 h-4 w-4 " />
{ctr.count} responses
</div>
</div>
</div>
<div className="space-y-5 rounded-b-lg bg-white px-6 pb-6 pt-4">
<div className="space-y-5 rounded-b-lg bg-white px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
<div>
<div className="text flex justify-between px-2 pb-2">
<div className="mr-8 flex space-x-1">

View File

@@ -14,6 +14,7 @@ interface LinkSurveyShareButtonProps {
export default function LinkSurveyShareButton({ survey, className }: LinkSurveyShareButtonProps) {
const [showLinkModal, setShowLinkModal] = useState(false);
return (
<>
<Button

View File

@@ -1,7 +1,8 @@
"use client";
import CodeBlock from "@/components/shared/CodeBlock";
import Modal from "@/components/shared/Modal";
// import Modal from "@/components/shared/Modal";
import { Dialog, DialogContent } from "@formbricks/ui";
import { TSurvey } from "@formbricks/types/v1/surveys";
import { Button } from "@formbricks/ui";
import { CheckIcon } from "@heroicons/react/24/outline";
@@ -40,8 +41,8 @@ top:0; width:100%; height:100%; border:0;">
};
return (
<Modal open={open} setOpen={setOpen} blur={false}>
<div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="bottom-0 max-w-sm bg-white p-4 sm:bottom-auto sm:max-w-xl sm:p-6">
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-teal-100">
<CheckIcon className="h-6 w-6 text-teal-600" aria-hidden="true" />
</div>
@@ -50,24 +51,32 @@ top:0; width:100%; height:100%; border:0;">
{showEmbed ? (
<div className="mt-4">
<p className="text-sm text-gray-500">Embed survey on your website:</p>
<CodeBlock language="html">{iframeCode}</CodeBlock>
<CodeBlock
customCodeClass="!whitespace-normal sm:!whitespace-pre-wrap !break-all sm:!break-normal"
language="html">
{iframeCode}
</CodeBlock>
</div>
) : (
<div className="mt-4">
<p className="text-sm text-gray-500">Share this link to let people answer your survey:</p>
<p
<div
ref={linkTextRef}
className="relative mt-3 w-full rounded-lg border border-slate-300 bg-slate-50 p-3 text-center text-slate-800"
className="relative mt-3 max-w-full overflow-auto rounded-lg border border-slate-300 bg-slate-50 p-3 text-center text-slate-800"
onClick={() => handleTextSelection()}>
{`${window.location.protocol}//${window.location.host}/s/${survey.id}`}
</p>
<span
style={{
wordBreak: "break-all",
}}>{`${window.location.protocol}//${window.location.host}/s/${survey.id}`}</span>
</div>
</div>
)}
<div className="mt-4 flex justify-end space-x-2">
<div className="mt-4 flex flex-col justify-center gap-2 sm:flex-row sm:justify-end">
<Button
variant="secondary"
title="Embed survey in your website"
aria-label="Embed survey in your website"
className="flex justify-center"
onClick={() => {
setShowEmbed(true);
navigator.clipboard.writeText(iframeCode);
@@ -87,6 +96,7 @@ top:0; width:100%; height:100%; border:0;">
}}
title="Copy survey link to clipboard"
aria-label="Copy survey link to clipboard"
className="flex justify-center"
EndIcon={DocumentDuplicateIcon}>
Copy URL
</Button>
@@ -94,6 +104,7 @@ top:0; width:100%; height:100%; border:0;">
variant="darkCTA"
title="Preview survey"
aria-label="Preview survey"
className="flex justify-center"
href={`${window.location.protocol}//${window.location.host}/s/${survey.id}?preview=true`}
target="_blank"
EndIcon={EyeIcon}>
@@ -101,7 +112,7 @@ top:0; width:100%; height:100%; border:0;">
</Button>
</div>
</div>
</div>
</Modal>
</DialogContent>
</Dialog>
);
}

View File

@@ -124,17 +124,19 @@ export default function MultipleChoiceSummary({
return (
<div className=" rounded-lg border border-slate-200 bg-slate-50 shadow-sm">
<div className="space-y-2 px-6 pb-5 pt-6">
<div className="space-y-2 px-4 pb-5 pt-6 md:px-6">
<div>
<h3 className="pb-1 text-xl font-semibold text-slate-900">{questionSummary.question.headline}</h3>
<h3 className="pb-1 text-lg font-semibold text-slate-900 md:text-xl">
{questionSummary.question.headline}
</h3>
</div>
<div className="flex space-x-2 font-semibold text-slate-600">
<div className="rounded-lg bg-slate-100 p-2 text-sm">
<div className="flex space-x-2 text-xs font-semibold text-slate-600 md:text-sm">
<div className="rounded-lg bg-slate-100 p-2">
{isSingleChoice
? "Multiple-Choice Single Select Question"
: "Multiple-Choice Multi Select Question"}
</div>
<div className=" flex items-center rounded-lg bg-slate-100 p-2 text-sm">
<div className="flex items-center rounded-lg bg-slate-100 p-2">
<InboxStackIcon className="mr-2 h-4 w-4 " />
{totalResponses} responses
</div>
@@ -144,11 +146,11 @@ export default function MultipleChoiceSummary({
</div> */}
</div>
</div>
<div className="space-y-5 rounded-b-lg bg-white px-6 pb-6 pt-4">
<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) => (
<div key={result.label}>
<div className="text flex justify-between px-2 pb-2">
<div className="mr-8 flex space-x-1">
<div className="text flex flex-col justify-between px-2 pb-2 sm:flex-row">
<div className="mr-8 flex w-full justify-between space-x-1 sm:justify-normal">
<p className="font-semibold text-slate-700">
{results.length - resultsIdx} - {result.label}
</p>
@@ -158,7 +160,7 @@ export default function MultipleChoiceSummary({
</p>
</div>
</div>
<p className="flex w-32 items-end justify-end text-slate-600">
<p className="flex w-full pt-1 text-slate-600 sm:items-end sm:justify-end sm:pt-0">
{result.count} {result.count === 1 ? "response" : "responses"}
</p>
</div>

View File

@@ -74,19 +74,21 @@ export default function NPSSummary({ questionSummary }: NPSSummaryProps) {
return (
<div className=" rounded-lg border border-slate-200 bg-slate-50 shadow-sm">
<div className="space-y-2 px-6 pb-5 pt-6">
<div className="space-y-2 px-4 pb-5 pt-6 md:px-6">
<div>
<h3 className="pb-1 text-xl font-semibold text-slate-900">{questionSummary.question.headline}</h3>
<h3 className="text-lg pb-1 font-semibold text-slate-900 md:text-xl">
{questionSummary.question.headline}
</h3>
</div>
<div className="flex space-x-2 font-semibold text-slate-600">
<div className="rounded-lg bg-slate-100 p-2 text-sm">Net Promoter Score (NPS)</div>
<div className=" flex items-center rounded-lg bg-slate-100 p-2 text-sm">
<div className="flex space-x-2 text-xs font-semibold text-slate-600 md:text-sm">
<div className="rounded-lg bg-slate-100 p-2">Net Promoter Score (NPS)</div>
<div className=" flex items-center rounded-lg bg-slate-100 p-2">
<InboxStackIcon className="mr-2 h-4 w-4 " />
{result.total} responses
</div>
</div>
</div>
<div className="space-y-5 bg-white px-6 pb-6 pt-4">
<div className="space-y-5 rounded-b-lg bg-white px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
{["promoters", "passives", "detractors"].map((group) => (
<div key={group}>
<div className="mb-2 flex justify-between">
@@ -107,7 +109,7 @@ export default function NPSSummary({ questionSummary }: NPSSummaryProps) {
))}
</div>
{dismissed.count > 0 && (
<div className="border-t bg-white px-6 pb-6 pt-4">
<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 className="text flex justify-between px-2 pb-2">
<div className="mr-8 flex space-x-1">

View File

@@ -18,13 +18,15 @@ function findEmail(person) {
export default function OpenTextSummary({ questionSummary, environmentId }: OpenTextSummaryProps) {
return (
<div className="rounded-lg border border-slate-200 bg-slate-50 shadow-sm">
<div className="space-y-2 px-6 pb-5 pt-6">
<div className="space-y-2 px-4 pb-5 pt-6 md:px-6">
<div>
<h3 className="pb-1 text-xl font-semibold text-slate-900">{questionSummary.question.headline}</h3>
<h3 className="text-lg pb-1 font-semibold text-slate-900 md:text-xl">
{questionSummary.question.headline}
</h3>
</div>
<div className="flex space-x-2 font-semibold text-slate-600">
<div className="rounded-lg bg-slate-100 p-2 text-sm">Open Text Question</div>
<div className=" flex items-center rounded-lg bg-slate-100 p-2 text-sm">
<div className="flex space-x-2 text-xs font-semibold text-slate-600 md:text-sm">
<div className="rounded-lg bg-slate-100 p-2 ">Open Text Question</div>
<div className=" flex items-center rounded-lg bg-slate-100 p-2">
<InboxStackIcon className="mr-2 h-4 w-4" />
{questionSummary.responses.length} Responses
</div>
@@ -32,9 +34,9 @@ export default function OpenTextSummary({ questionSummary, environmentId }: Open
</div>
<div className="rounded-b-lg bg-white ">
<div className="grid h-10 grid-cols-4 items-center border-y border-slate-200 bg-slate-100 text-sm font-bold text-slate-600">
<div className="pl-6">User</div>
<div className="col-span-2 pl-6">Response</div>
<div className="px-6">Time</div>
<div className="pl-4 md:pl-6">User</div>
<div className="col-span-2 pl-4 md:pl-6">Response</div>
<div className="px-4 md:px-6">Time</div>
</div>
{questionSummary.responses.map((response) => {
const email = response.person && findEmail(response.person);
@@ -42,29 +44,32 @@ export default function OpenTextSummary({ questionSummary, environmentId }: Open
return (
<div
key={response.id}
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-slate-800">
<div className="pl-6">
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}`}>
<PersonAvatar personId={response.person.id} />
<p className="ph-no-capture ml-2 text-slate-600 group-hover:underline">
<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">
<PersonAvatar personId="anonymous" />
<p className="ml-2 text-slate-600">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 className="ph-no-capture col-span-2 whitespace-pre-wrap pl-6 font-semibold">
{response.value}
</div>
<div className="px-6 text-slate-500">{timeSince(response.updatedAt.toISOString())}</div>
<div className="px-4 text-slate-500 md:px-6">{timeSince(response.updatedAt.toISOString())}</div>
</div>
);
})}

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