Compare commits

..

153 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
github-actions[bot]
73904e11a6 Update formbricks-js to 1.0.2 (#640)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2023-08-02 16:12:39 +02:00
Meet Patel
c423e43aee refined search for who completed onboarding 2023-08-02 19:39:38 +05:30
Matti Nannt
a1b447caad Increase formbricks-js z-index to 999999 to increase compatibility with more websites (#639)
* Fix formbricks-js modal covered by other elements

* Fix wrong usage of prefix

* add changeset
2023-08-02 15:59:16 +02:00
Johannes
6b989487b2 Fix SEMRush SEO issues & move OSS friends to static
Fix SEMRush SEO issues & move OSS friends to static
2023-08-02 07:40:45 -05:00
Johannes
d60e0c4e5c fix SEO issues and move OSS friends to static 2023-08-02 14:24:26 +02:00
Shubham Palriwala
2a3ab3280f Fix NEXTAUTH_SECRET not get filled correctly in deployment script (#632)
* feat: handle openssl producing special characters that were causing errrs for sed to read

* feat: use all variables in dockerfile from the sole env itself
2023-08-02 13:33:29 +02:00
Matti Nannt
5b217e5483 Update pnpm-lock to solve build issues (#636) 2023-08-02 13:20:03 +02:00
tyjkerr
ec0d3f2fa2 Add Back Button to Surveys (#501)
* add back button, next with local storaage wip

* handle submission and skip submission logic

* handle showing stored value on same concurrent question type.

* remove console.log

* fix next button not showing, add saving answer on pressing back to local storage

* add temp props to QuestionCondition in preview modal

* add temp props to QuestionCondition in preview modal again...

* update navigation logic

* update survey question preview

* add back-button component

* add back button to formbricks/js

* refactor localStorage functions to lib

* remove unused import

* add form prefilling when reloading forms

* merge main into branch

* Revert "merge main into branch"

This reverts commit 13bc9c06ec.

* rename localStorage key answers->responses

* rename answers -> responses in linkSurvey lib

* when survey page reloaded jump to next question instead of current question

* rename getStoredAnswer -> getStoredResponse

* continue renaming

* continue renaming

* rename answerValue -> responseValue

---------

Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2023-08-02 13:08:20 +02:00
Johannes
ae702ddd06 Add Twenty.com to OSS friends
Add Twenty.com to OSS friends
2023-08-02 04:34:19 -05:00
Johannes
91f78d875b Add Twenty.com to OSS friends 2023-08-02 04:34:00 -05:00
Johannes
08110b0c34 LP: Add OSS friends via API and update pricing wording
LP: Add OSS friends via API and update pricing wording
2023-08-02 03:59:31 -05:00
Johannes
42e6601f13 update fetch URL 2023-08-02 10:47:26 +02:00
Johannes
a5c33981a0 update pricing wording, add OSS friends API 2023-08-02 10:29:06 +02:00
Johannes
1a90d1b7e8 Merge branch 'main' of github.com:formbricks/formbricks into lp/add-oss-friends 2023-08-02 09:57:03 +02:00
Meet Patel
712431e842 search box improved & default category set 2023-08-01 23:32:00 +05:30
Johannes
3905c2227e Patch: Close survey on date can be set to past
Patch: Close survey on date can be set to past
2023-08-01 03:33:37 -05:00
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
Matthias Nannt
5ae7f31d01 update pnpm lock 2023-07-25 16:14:00 +02:00
Matthias Nannt
cb4cd706ad Merge branch 'main' of github.com:formbricks/formbricks into feat/close-date-edge-case 2023-07-25 16:00:05 +02:00
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
Johannes
2f8257ae62 added new members 2023-07-22 13:41:15 +02:00
Johannes
8a5217b39c OSS Api 2023-07-22 13:26:33 +02:00
Piyush Gupta
57e6c86e6a refactor: summary header 2023-07-22 11:02:59 +05:30
Piyush Gupta
4519cb8a2d Merge branch 'main' of https://github.com/gupta-piyush19/formbricks into feat/close-date-edge-case 2023-07-21 20:38:22 +05:30
Piyush Gupta
b20cda2d06 fix: removed today's date from closeOnDate date picker 2023-07-19 00:11:01 +05:30
Piyush Gupta
6e8be0c0bd fixed merge conflict 2023-07-18 19:40:01 +05:30
Piyush Gupta
c68a9c8d15 fix: edge case of close on date 2023-07-18 19:38:03 +05:30
242 changed files with 7788 additions and 3306 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

@@ -2,18 +2,18 @@ import { CodeFileIcon, EyeIcon, HandPuzzleIcon } from "@formbricks/ui";
import HeadingCentered from "../shared/HeadingCentered";
const features = [
{
id: "compliance",
name: "Smoothly Compliant",
description: "Use our GDPR-compliant Cloud or self-host the entire solution.",
icon: EyeIcon,
},
{
id: "customizable",
name: "Fully Customizable",
description: "Full customizability and extendability. Integrate with your stack easily.",
icon: HandPuzzleIcon,
},
{
id: "compliance",
name: "Smoothly Compliant",
description: "Self-host the entire product and fly through privacy compliance reviews.",
icon: EyeIcon,
},
{
id: "independent",
name: "Stay independent",
@@ -27,9 +27,9 @@ export const Features: React.FC = () => {
<div className="relative mx-auto max-w-7xl">
<HeadingCentered
closer
teaser="DATA Privacy at heart"
teaser="Data Privacy at heart"
heading="The only open-source solution"
subheading="Comply with all data privacy regulation with ease. Simply self-host."
subheading="Comply with all data privacy regulation with ease. Self-host if you want."
/>
<ul role="list" className="grid grid-cols-1 gap-4 pt-8 sm:grid-cols-2 md:grid-cols-3 lg:gap-10">

View File

@@ -9,7 +9,7 @@ export const GitHubSponsorship: React.FC = () => {
<style jsx>{`
@media (min-width: 426px);
`}</style>
<div className="right-10 lg:absolute">
<div className="right-24 lg:absolute">
<Image
src={GitHubMarkDark}
alt="GitHub Sponsors Formbricks badge"

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

@@ -51,7 +51,7 @@ export default function Footer() {
<p className="text-base text-slate-500 dark:text-slate-400">Privacy-first Experience Management</p>
<div className="border-slate-500">
<p className="text-sm text-slate-400 dark:text-slate-500">
&copy; 2022. All rights reserved.
Formbricks GmbH &copy; 2022. All rights reserved.
<br />
<Link href="/imprint">Imprint</Link> | <Link href="/privacy">Privacy Policy</Link> |{" "}
<Link href="/terms">Terms</Link> | <Link href="/oss-friends">OSS Friends</Link>

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

@@ -26,39 +26,39 @@ const tiers = [
href: "/docs/self-hosting/deployment",
},
{
name: "Free",
name: "Cloud",
href: "https://app.formbricks.com/auth/signup",
priceMonthly: "$0",
paymentRythm: "/month",
button: "highlight",
discounted: false,
highlight: true,
description: "All Pro features included.",
description: "Start with the 'Free forever' plan.",
features: [
"Unlimited surveys",
"Unlimited team members",
"Remove branding",
"Granular targeting",
"In-product surveys",
"Link surveys",
"Remove branding",
"Granular targeting",
"30+ templates",
"API access",
"Integrations (Slack, PostHog, Zapier)",
"Integrations (Zapier, Make, ...)",
"Unlimited team members",
"100 responses per survey",
],
ctaName: "Start for free",
ctaName: "Get started",
plausibleGoal: "Pricing_CTA_FreePlan",
},
{
name: "Pro",
name: "Cloud Pro",
href: "https://app.formbricks.com/auth/signup",
priceMonthly: "$99",
paymentRythm: "/month",
button: "secondary",
discounted: false,
highlight: false,
description: "All features included. Unlimited usage.",
features: ["Unlimited responses per survey"],
description: "All features, unlimited usage.",
features: ["Everything in 'Cloud'", "Unlimited responses per survey"],
ctaName: "Start for free",
plausibleGoal: "Pricing_CTA_ProPlan",
},
@@ -146,9 +146,12 @@ export default function Pricing() {
{tier.ctaName}
</Button>
{tier.name !== "Self-hosting" && (
{tier.name == "Cloud Pro" && (
<p className="mt-1.5 text-center text-xs text-slate-500">No Creditcard required.</p>
)}
{tier.name == "Cloud" && (
<p className="mt-1.5 text-center text-xs text-slate-500">Free forever 🤍</p>
)}
</div>
</div>
))}

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

@@ -1,10 +1,134 @@
import { OSSFriends } from "@/pages/oss-friends";
import type { NextApiRequest, NextApiResponse } from "next";
export default async function handle(req: NextApiRequest, res: NextApiResponse) {
// GET
if (req.method === "GET") {
return res.status(200).json({ data: OSSFriends });
return res.status(200).json({
data: [
{
name: "Appsmith",
description: "Build build custom software on top of your data.",
href: "https://www.appsmith.com",
},
{
name: "BoxyHQ",
description:
"BoxyHQs suite of APIs for security and privacy helps engineering teams build and ship compliant cloud applications faster.",
href: "https://boxyhq.com",
},
{
name: "Cal.com",
description:
"Cal.com is a scheduling tool that helps you schedule meetings without the back-and-forth emails.",
href: "https://cal.com",
},
{
name: "Crowd.dev",
description:
"Centralize community, product, and customer data to understand which companies are engaging with your open source project.",
href: "https://www.crowd.dev",
},
{
name: "Documenso",
description:
"The Open-Source DocuSign Alternative. We aim to earn your trust by enabling you to self-host the platform and examine its inner workings.",
href: "https://documenso.com",
},
{
name: "Erxes",
description:
"The Open-Source HubSpot Alternative. A single XOS enables to create unique and life-changing experiences that work for all types of business.",
href: "https://erxes.io",
},
{
name: "Formbricks",
description:
"Survey granular user segments at any point in the user journey. Gather up to 6x more insights with targeted micro-surveys. All open-source.",
href: "https://formbricks.com",
},
{
name: "GitWonk",
description:
"GitWonk is an open-source technical documentation tool, designed and built focusing on the developer experience.",
href: "https://gitwonk.com",
},
{
name: "Hanko",
description:
"Open-source authentication and user management for the passkey era. Integrated in minutes, for web and mobile apps.",
href: "https://www.hanko.io",
},
{
name: "HTMX",
description:
"HTMX is a dependency-free JavaScript library that allows you to access AJAX, CSS Transitions, WebSockets, and Server Sent Events directly in HTML.",
href: "https://htmx.org",
},
{
name: "Infisical",
description:
"Open source, end-to-end encrypted platform that lets you securely manage secrets and configs across your team, devices, and infrastructure.",
href: "https://infisical.com",
},
{
name: "Mockoon",
description: "Mockoon is the easiest and quickest way to design and run mock REST APIs.",
href: "https://mockoon.com",
},
{
name: "Novu",
description:
"The open-source notification infrastructure for developers. Simple components and APIs for managing all communication channels in one place.",
href: "https://novu.co",
},
{
name: "OpenBB",
description:
"Democratizing investment research through an open source financial ecosystem. The OpenBB Terminal allows everyone to perform investment research, from everywhere.",
href: "https://openbb.co",
},
{
name: "Sniffnet",
description:
"Sniffnet is a network monitoring tool to help you easily keep track of your Internet traffic.",
href: "https://www.sniffnet.net",
},
{
name: "Tolgee",
description: "Software localization from A to Z made really easy.",
href: "https://tolgee.io/",
},
{
name: "Trigger.dev",
description:
"Create long-running Jobs directly in your codebase with features like API integrations, webhooks, scheduling and delays.",
href: "https://trigger.dev",
},
{
name: "Typebot",
description:
"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.",
href: "https://twenty.com",
},
{
name: "Webiny",
description:
"Open-source enterprise-grade serverless CMS. Own your data. Scale effortlessly. Customize everything.",
href: "https://www.webiny.com",
},
{
name: "Webstudio",
description: "Webstudio is an open source alternative to Webflow",
href: "https://webstudio.is",
},
],
});
}
// Unknown HTTP Method

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

@@ -7,7 +7,7 @@ import BestPracticeNavigation from "@/components/shared/BestPracticeNavigation";
export default function DocsFeedbackPage() {
return (
<Layout
title="Feedback Box"
title="Docs Feedback"
description="The better your docs, the higher your user adoption. Measure granularly how clear your documentation is.">
<div className="grid grid-cols-1 items-center md:grid-cols-2 md:gap-12 md:py-20">
<div className="p-6 md:p-0">

View File

@@ -33,7 +33,7 @@ The API requests are authorized with a personal API key. This API key gives you
### Delete a personal API key
1. Go to settings on [app.formbricks.com](https://app.formbricks.com/me/settings).
1. Go to settings on [app.formbricks.com](https://app.formbricks.com/).
2. Go to page “API keys”.
3. Find the key you wish to revoke and select “Delete”.
4. Your API key will stop working immediately.

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

@@ -3,7 +3,7 @@ import { Fence } from "@/components/shared/Fence";
export const meta = {
title: "Webhook Payload",
description: "Learn how to use the Formbricks Webhook API.",
description: "Learn how to handle the Formbricks API payload.",
};
This documentation helps understand the payload structure that will be received when the webhook is triggered in Formbricks.

View File

@@ -2,6 +2,7 @@ import Layout from "@/components/shared/LayoutMdx";
export const meta = {
title: "Imprint",
description: "Imprint of formbricks.com",
};
## Information according to § 5 TMG
@@ -17,19 +18,24 @@ E-Mail: hola@formbricks.com
## EU dispute resolution
The European Commission provides a platform for online dispute resolution (OS): https://ec.europa.eu/consumers/odr.\
You can find our e-mail address in the imprint above.\
Consumer dispute resolution/universal dispute resolution body\
The European Commission provides a platform for online dispute resolution (OS): https://ec.europa.eu/consumers/odr
You can also reach out via the e-mail address in the imprint above.
### Consumer dispute resolution/universal dispute resolution body
We are not willing or obliged to participate in dispute resolution proceedings before a consumer arbitration board.
## Liability for contents
As a service provider, we are responsible for our own content on these pages in accordance with § 7 paragraph 1 TMG under the general laws. According to §§ 8 to 10 TMG, we are not obligated to monitor transmitted or stored information or to investigate circumstances that indicate illegal activity.\
Obligations to remove or block the use of information under the general laws remain unaffected. However, liability in this regard is only possible from the point in time at which a concrete infringement of the law becomes known. If we become aware of any such infringements, we will remove the relevant content immediately.
## Liability for links
Our offer contains links to external websites of third parties, on whose contents we have no influence. Therefore, we cannot assume any liability for these external contents. The respective provider or operator of the sites is always responsible for the content of the linked sites. The linked pages were checked for possible legal violations at the time of linking. Illegal contents were not recognizable at the time of linking.\
However, a permanent control of the contents of the linked pages is not reasonable without concrete evidence of a violation of the law. If we become aware of any infringements, we will remove such links immediately.
## Copyright

View File

@@ -2,105 +2,17 @@ import Layout from "@/components/shared/Layout";
import HeroTitle from "@/components/shared/HeroTitle";
import { Button } from "@formbricks/ui";
export const OSSFriends = [
{
name: "BoxyHQ",
description:
"BoxyHQs suite of APIs for security and privacy helps engineering teams build and ship compliant cloud applications faster.",
href: "https://boxyhq.com",
},
{
name: "Cal.com",
description:
"Cal.com is a scheduling tool that helps you schedule meetings without the back-and-forth emails.",
href: "https://cal.com",
},
{
name: "Crowd.dev",
description:
"Centralize community, product, and customer data to understand which companies are engaging with your open source project.",
href: "https://www.crowd.dev",
},
{
name: "Documenso",
description:
"The Open-Source DocuSign Alternative. We aim to earn your trust by enabling you to self-host the platform and examine its inner workings.",
href: "https://documenso.com",
},
{
name: "Erxes",
description:
"The Open-Source HubSpot Alternative. A single XOS enables to create unique and life-changing experiences that work for all types of business.",
href: "https://erxes.io",
},
{
name: "Formbricks",
description:
"Survey granular user segments at any point in the user journey. Gather up to 6x more insights with targeted micro-surveys. All open-source.",
href: "https://formbricks.com",
},
{
name: "GitWonk",
description:
"GitWonk is an open-source technical documentation tool, designed and built focusing on the developer experience.",
href: "https://gitwonk.com",
},
{
name: "Hanko",
description:
"Open-source authentication and user management for the passkey era. Integrated in minutes, for web and mobile apps.",
href: "https://www.hanko.io",
},
{
name: "HTMX",
description:
"HTMX is a dependency-free JavaScript library that allows you to access AJAX, CSS Transitions, WebSockets, and Server Sent Events directly in HTML.",
href: "https://htmx.org",
},
{
name: "Infisical",
description:
"Open source, end-to-end encrypted platform that lets you securely manage secrets and configs across your team, devices, and infrastructure.",
href: "https://infisical.com",
},
{
name: "Novu",
description:
"The open-source notification infrastructure for developers. Simple components and APIs for managing all communication channels in one place.",
href: "https://novu.co",
},
{
name: "OpenBB",
description:
"Democratizing investment research through an open source financial ecosystem. The OpenBB Terminal allows everyone to perform investment research, from everywhere.",
href: "https://openbb.co",
},
{
name: "Sniffnet",
description:
"Sniffnet is a network monitoring tool to help you easily keep track of your Internet traffic.",
href: "https://www.sniffnet.net",
},
{
name: "Typebot",
description:
"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: "Webiny",
description:
"Open-source enterprise-grade serverless CMS. Own your data. Scale effortlessly. Customize everything.",
href: "https://www.webiny.com",
},
{
name: "Webstudio",
description: "Webstudio is an open source alternative to Webflow",
href: "https://webstudio.is",
},
];
type OSSFriend = {
href: string;
name: string;
description: string;
};
export default function OSSFriendsPage() {
type Props = {
OSSFriends: OSSFriend[];
};
export default function OSSFriendsPage({ OSSFriends }: Props) {
return (
<Layout title="OSS Friends" description="Open-source projects and tools for an open world.">
<HeroTitle headingPt1="Our" headingTeal="Open-source" headingPt2="Friends" />
@@ -122,3 +34,16 @@ export default function OSSFriendsPage() {
</Layout>
);
}
export async function getStaticProps() {
const res = await fetch("https://formbricks.com/api/oss-friends");
const data = await res.json();
// By returning { props: { OSSFriends } }, the OSSFriendsPage component
// will receive `OSSFriends` as a prop at build time
return {
props: {
OSSFriends: data.data,
},
};
}

View File

@@ -2,6 +2,7 @@ import Layout from "@/components/shared/LayoutMdx";
export const meta = {
title: "Privacy Policy",
description: "Formbricks Privacy Policy",
};
## **1. Introduction**

View File

@@ -3,6 +3,7 @@ import { Callout } from "@/components/shared/Callout";
export const meta = {
title: "Terms of Service",
description: "Terms of Service of Formbricks Cloud.",
};
These Terms of Use constitute a legally binding agreement made between you, whether personally or on behalf of an entity (“you”) and Formbricks ("**Company**", “**we**”, “**us**”, or “**our**”), concerning your access to and use of the https://formbricks.com website as well as any other media form, media channel, mobile website or mobile application related, linked, or otherwise connected thereto (collectively, the “Site”). You agree that by accessing the Site, you have read, understood, and agree to be bound by all of these Terms of Use. If you do not agree with all of these terms of use, then you are expressly prohibited from using the site and you must discontinue use immediately.

View File

@@ -1,5 +1,27 @@
# @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
- Updated dependencies [a1b447ca]
- @formbricks/js@1.0.2
## 0.1.1
### 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>
)}
</>
);
}

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