Compare commits

...

106 Commits

Author SHA1 Message Date
Matthias Nannt
f282f3d081 limit matrix check to ubuntu 2022-10-18 14:19:13 +02:00
Matthias Nannt
3e63e536e0 apply prettier in react lib, make packages private 2022-10-18 13:41:48 +02:00
Matthias Nannt
d5b5fd8dd9 make config packages public 2022-10-18 13:31:22 +02:00
Matthias Nannt
3dafcb6d32 update package names, update config imports 2022-10-18 13:26:05 +02:00
Matthias Nannt
eea8f678bd ignore web package in changeset 2022-10-18 12:34:41 +02:00
github-actions[bot]
4e5b03e62d Version Packages (#116)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2022-10-18 12:33:50 +02:00
Matti Nannt
28b6410dbb Feature/add react lib to monorepo #107 (#115)
* add changesets

* add react-app to monorepo
2022-10-18 12:24:17 +02:00
Kiran K
5a7492536a Refactor EmailQuestion Component (#109) 2022-10-18 10:57:53 +02:00
Matti Nannt
8b0347ab8a Create .dockerignore 2022-10-18 10:51:10 +02:00
Matthias Nannt
baf57883b0 add NEXTAUTH_URL_INTERNAL to .env.docker 2022-10-18 10:24:13 +02:00
Matthias Nannt
d6775a5cda fix user signup disabled env could be set to anything to disable signup 2022-10-18 10:23:41 +02:00
Chetan Sarva
38366c5336 build: add NEXTAUTH_URL_INTERNAL to example env (#108)
Co-authored-by: Matti Nannt <mail@matthiasnannt.com>
2022-10-18 10:21:15 +02:00
Matti Nannt
a4c571ffd3 add NEXT_PUBLIC_SIGNUP_DISABLED env variable (#114) 2022-10-18 10:11:21 +02:00
Matthias Nannt
35bda9d1de update .env.example file 2022-10-16 17:17:38 +02:00
Matthias Nannt
619bcb3a1f use config redirect for /forms/:id to avoid serverless function 2022-10-16 11:30:22 +02:00
Matti Nannt
6cd3878700 make all env variables required at build time #110 (#111)
* make all env variables required at build time

* update env files

* add .env.docker file with basic docker configuration

* update Readme with new instructions
2022-10-16 11:10:20 +02:00
Matthias Nannt
e910a97d32 update dockerfile to env file 2022-10-15 16:53:23 +02:00
Matthias Nannt
a0ba4ef9d3 add docker env example 2022-10-15 12:41:02 +02:00
Matthias Nannt
b6e24c207a Merge branch 'main' of github.com:snoopForms/snoopforms 2022-10-14 09:58:18 +02:00
Matthias Nannt
35ba2a1936 update dev instructions in README 2022-10-14 09:57:54 +02:00
Matti Nannt
56e9c04659 fix Github Workflow for Dockerfile (#106)
* fix location of Dockerfile
2022-10-13 12:02:07 +02:00
Matti Nannt
5c378bc8ce Feature/monorepo #95 (#105)
Move repository into a monorepo with turborepo and pnpm.
This is a big change in the way the code is organized, used and deployed.
2022-10-13 09:46:43 +02:00
Matthias Nannt
2d63249f63 bugfix links in nocode paragraph not working in app (#94) 2022-10-10 10:57:51 +02:00
Matthias Nannt
0a8eefbd63 Create CONTRIBUTING.md 2022-09-30 13:43:01 +02:00
Matthias Nannt
b48dc100f4 Rename LICENCE to LICENSE 2022-09-29 14:40:57 +02:00
Matthias Nannt
f67365f6dd Add product hunt & discord online users to Readme 2022-09-23 16:20:28 +02:00
Matthias Nannt
6ae4566baf fix Multi choice question: Option labels cut off #84 2022-09-23 12:59:10 +02:00
Matthias Nannt
0345247e9d add formType to posthog pageSubmission 2022-09-21 17:38:25 +02:00
Matthias Nannt
dbdfdedf20 update links in readme 2022-09-21 14:33:20 +02:00
Matthias Nannt
13cc2af59a fix error while loading responses #81 2022-09-20 22:37:29 +02:00
Matthias Nannt
c5450bd6ee Merge branch 'main' of github.com:snoopForms/snoopforms 2022-09-20 10:09:08 +02:00
Matthias Nannt
de8173bc84 update react library version with fixed radio buttons #85 2022-09-20 10:08:38 +02:00
npaulsen
67638bb70a Feature/devcontainer (#79)
* fixes committed conflict in README

* adds a devcontainer based on compose

includes mailhog and postgres containers

* adds launch.json for nextjs vscode quick actions

* mentions devcontainer in the readme

* auto prisma migration after creating devcontainer

* remote containers as recommended vscode extension

* adds nextauth url env to dev container

- to show correct links in mails when testing locally

Co-authored-by: Niklas Paulsen <npau@informatik.uni-kiel.de>
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2022-09-20 09:20:59 +02:00
Matthias Nannt
36bf9cc997 Merge branch 'main' of github.com:snoopForms/snoopforms 2022-09-19 19:39:48 +02:00
Matthias Nannt
349fe3cb6c add long text question type 2022-09-19 19:39:38 +02:00
Manuel
9c6d08e762 Remove Merge conflict remainder in Readme.md (#83)
* Remove Merge conflict remainder in Readme.md

Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2022-09-19 13:12:03 +02:00
Matthias Nannt
08278c8b0f remove truncation in summary display 2022-09-15 13:37:09 +02:00
Matthias Nannt
3148d91fc9 fix: error hashing ownerId in posthog capture method 2022-09-15 13:29:57 +02:00
Timothy
64d84b69db #21: Add helperText to all question types (#61)
* #21: Add help texts to all question types

Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2022-09-02 16:19:24 +02:00
Matthias Nannt
db01eafb24 remove user fingerprinting 2022-09-02 16:18:26 +02:00
Matthias Nannt
68ee24189c add formType to posthog form created event 2022-09-02 14:25:21 +02:00
Matthias Nannt
5be849b553 add imports to react quickstart #70 2022-08-31 09:07:33 +02:00
Matthias Nannt
3311cc4ab6 fix posthog capture spelling issue, rename pageSubmission events 2022-08-31 08:33:14 +02:00
Matthias Nannt
e2bbb6e5a9 Feature/webhook (#75)
* Add webhook functionality #24
2022-08-31 08:22:31 +02:00
Matthias Nannt
c674d58c69 Fix Posthog Tracking (#71)
* Use simple API calls for Posthog Tracking to increase compatibility
2022-08-25 18:59:41 +02:00
Matthias Nannt
f26e14df12 clean up unused code 2022-08-25 16:09:28 +02:00
Tim Lange
8a875922b3 Added forgot password ability (#63)
* NEW: Added forgot password ability

* FIX: Added password reset disabled flag & jwt verify

Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2022-08-25 16:01:36 +02:00
Matthias Nannt
a0ed5b7c0c bugfix smtp secure variable, simplify telemetry 2022-08-24 21:31:27 +02:00
Matthias Nannt
246698ca02 fix posthog issue, introduce telemetry for self-hosted version 2022-08-24 20:46:25 +02:00
Matthias Nannt
ce02d2906a fix scrolling not working in nocode editor 2022-08-22 16:40:15 +02:00
Matthias Nannt
9c85190e7c add imprint & privacy policy to public form frontend 2022-08-22 16:20:53 +02:00
Matthias Nannt
cede0a83bf Merge changes 2022-08-18 08:13:37 +02:00
Matthias Nannt
1a64baf83e bugfix not showing loading indicator when loading form frontend 2022-08-17 23:13:35 +02:00
Matthias Nannt
f4f248860b disable posthog autotracking, send specific events via backend (#62) 2022-08-17 22:57:53 +02:00
Matthias Nannt
b48018b2f8 bugfix delete submission, let prisma only print warning and errors 2022-08-17 20:24:05 +02:00
Timothy
844c590d7c Feature/#41 unpublish forms (#59)
* #41: Display message for unpublished forms

* #41: Add publish/unpublish GUI

* #41: Revamp UI and add 'closed' parameter

* change indigo color of access-switch to red

* update live-form on republish, show error message to user in frontend  when form unpublished

Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2022-08-17 16:41:22 +02:00
Matthias Nannt
2b41caaf1e add issue template 2022-08-17 14:43:49 +02:00
Matthias Nannt
f807ccf6cc update readme and setup instructions 2022-08-17 09:20:54 +02:00
Matthias Nannt
6bf178fd85 update version number 2022-08-16 14:17:04 +02:00
Matthias Nannt
c2d4a48fe3 update github actions to add tags to docker images, only support node 16 2022-08-16 14:01:22 +02:00
Matthias Nannt
973f999756 fix responsiveness in responses 2022-08-16 13:46:07 +02:00
Matthias Nannt
aa6c254872 Truncate formname fixes #56 (#60)
* truncate in formlist

* truncate in breadcrumbs
2022-08-16 12:01:36 +02:00
Timothy
5bb739f547 Implement single submission deletion (#57)
* implement single submission deletion

* modify delete submission button and add confirm

Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2022-08-14 12:32:26 +02:00
Timothy
10e3db34a9 Add default flow if SMTP data not provided (#54)
* Add default flow if SMTP data not provided

* use explicit env var for disabling email verification

* #26: Check for strict equality
2022-08-14 11:11:23 +02:00
Timothy
f1bede5816 #49: Update 404 page (#58)
* #49: Update 404 page

* #49: Fix typos
2022-08-14 11:01:43 +02:00
Matthias Nannt
419e12777a update date-fns package to fix pipeline issues 2022-08-11 12:01:22 +02:00
Matthias Nannt
954adfa815 bugfix error in submissions on altered form #55 2022-08-11 11:47:54 +02:00
Johannes
19b29188dd fix:fix z-index on form list, unify context menus (#16) 2022-08-10 22:57:41 +09:00
Matthias Nannt
803007e0e2 Feature/fix authentication issues (#15)
* fix errors when not authenticated
* update syntax highlighting and react code sample
2022-08-09 06:36:39 +09:00
Johannes
28452e3c89 fix for empty tooltip & code example update (#10)
* fix tooltip bug, add highlightJs for code examples
2022-08-08 21:42:55 +09:00
Matthias Nannt
758474ec25 fix telemetry-disable env variable 2022-08-08 09:21:57 +02:00
Matthias Nannt
98fadfd476 fix client-side exception on analytics page #14 2022-08-05 15:38:49 +02:00
Timothy
37d284ebaa Clarifying README.md (#13) 2022-08-05 04:53:27 +09:00
Matthias Nannt
c49aad505b update readme 2022-08-03 09:38:47 +02:00
Matthias Nannt
16650ff076 add github worklow to check lint and build errors on push 2022-08-03 09:09:12 +02:00
Matthias Nannt
146a8f6608 add getInitialProps to _app to enable next/config everywhere 2022-08-03 08:59:36 +02:00
Matthias Nannt
75d7700d25 remove superjson and custom babel config, bugfix lastSubmission bug when no values present 2022-08-03 08:53:04 +02:00
Benjamin Paul
6bddc87021 Fixed missing required environment variables (#12)
Co-authored-by: Ben Paul <ben.paul@justeattakeaway.com>
2022-08-02 23:51:27 +09:00
Matthias Nannt
e4129b23fa simplify env var names for terms and privacy 2022-08-02 08:52:05 +02:00
Matthias Nannt
3190f6556a move privacy and term envs into next config 2022-08-01 22:16:57 +02:00
Matthias Nannt
8391dceed1 add signup footer with terms and privacy if env variables for these documents are set 2022-08-01 21:44:14 +02:00
Matthias Nannt
602e8d3672 simplify posthog integration 2022-08-01 09:02:37 +02:00
Matthias Nannt
fcdeabb170 update snoop-react version, bugfix submit button label not saved 2022-07-30 20:11:56 +02:00
Matthias Nannt
6cc4ecadef update snoop-react version, fix pipeline layout 2022-07-29 11:32:10 +02:00
Matthias Nannt
085a5aeed7 add loading state to results analytics 2022-07-29 09:49:16 +02:00
knugget
7ba0c66645 Merge branch 'main' of https://github.com/snoopForms/snoopforms into main 2022-07-29 09:35:46 +02:00
Matthias Nannt
f3c9c4a99f bugfix chartjs error 2022-07-29 00:54:20 +02:00
Matthias Nannt
aa648f9b2d Feature/add question types (#9)
* add new question types: email, website, phone, number & multiple choice
2022-07-29 07:21:32 +09:00
knugget
54a217ee1e Sign up wording 🤸 2022-07-28 13:07:17 +02:00
Johannes
6da264f432 Update README.md 2022-07-25 04:13:24 -05:00
Johannes
c1ca0ea568 Wording update (#8)
* Updated "Thank You" Toast 🙏

* Sign up wording 🤸
2022-07-25 10:43:23 +02:00
knugget
24dc1f6a02 Merge branch 'main' of https://github.com/snoopForms/snoopforms into main 2022-07-25 08:24:51 +02:00
knugget
2c196db36d Updated "Thank You" Toast 🙏 2022-07-25 08:24:48 +02:00
Matthias Nannt
277bd014ae add posthog integration (#7)
* add possibility to track events with posthog by setting environment variables POSTHOG_API_KEY and POSTHOG_API_HOST
2022-07-22 23:15:39 +09:00
Matthias Nannt
cb6b76c3e2 fix infinite loading in app, fix signout error 2022-07-20 15:07:46 +02:00
Matthias Nannt
c20167342f Rewrite Signup & Signin to make it more robust (#6)
* remove explicit CSRF-token handling

* use NEXTAUTH_SECRET secret to better comply with nextAuth standards

* update next.js and nextAuth

* remove ECR github action

* add name to mail-from
2022-07-20 21:19:42 +09:00
Matthias Nannt
3afe4a8a97 update nextjs & nextauth, improve loading-indicator, improve auth-redirect-flow 2022-07-20 09:39:22 +02:00
Matthias Nannt
5539ec59b3 update ecr action to push with latest tag 2022-07-19 21:55:12 +02:00
Matthias Nannt
71dcf17e17 add ecr image build & deploy action 2022-07-19 21:35:16 +02:00
Matthias Nannt
5e229057ff fix docker-image action with lowercase repository 2022-07-19 16:32:47 +09:00
Matthias Nannt
9f262cffde Create Github Action for Docker Publish & Deploy 2022-07-19 16:24:26 +09:00
Matthias Nannt
fa268630a5 move environment variables to nextjs runtime config 2022-07-19 08:44:51 +02:00
Matthias Nannt
8aa55bf8ae remove apprunner config, add migrate-and-start script, disable next telemetry with environment variable 2022-07-14 15:07:29 +02:00
Matthias Nannt
13462fccee add apprunner config 2022-07-13 21:15:34 +02:00
Matthias Nannt
0aa931287f Feature/add signup (#5)
* update prisma model to support user

* add signup page

* add email verification

* remove seed user

* update README and Dockerfile to support signup and the removal of seed data
2022-07-14 01:04:02 +09:00
Matthias Nannt
4192461f5f Updated Editor-Approach for easier multi-page management (#4)
* use new editor approach where we use a single editorJS instance and a pageTransition block to indicate the start of a new page
* improve toast messages in editor
* add keyboard listener and toast to showcase autosaving functionality
* bugfix first and secondary menu positioning
* add required functionality to textQuestion
2022-06-29 15:24:11 +09:00
272 changed files with 13741 additions and 5307 deletions

View File

@@ -1,4 +0,0 @@
{
"presets": ["next/babel"],
"plugins": ["superjson-next"]
}

8
.changeset/README.md Normal file
View File

@@ -0,0 +1,8 @@
# Changesets
Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
with multi-package repos, or single-package repos to help you version and publish your code. You can
find the full documentation for it [in our repository](https://github.com/changesets/changesets)
We have a quick list of common questions to get you started engaging with this project in
[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md)

11
.changeset/config.json Normal file
View File

@@ -0,0 +1,11 @@
{
"$schema": "https://unpkg.com/@changesets/config@2.2.0/schema.json",
"changelog": "@changesets/cli/changelog",
"commit": false,
"fixed": [],
"linked": [],
"access": "restricted",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": ["@formbricks/web"]
}

6
.dockerignore Normal file
View File

@@ -0,0 +1,6 @@
Dockerfile
.dockerignore
.turbo
node_modules
README.md
.next

95
.env.docker Normal file
View File

@@ -0,0 +1,95 @@
########################################################################
# ------------ MANDATORY (CHANGE ACCORDING TO YOUR SETUP) ------------#
########################################################################
############
# Basics #
############
NEXTAUTH_SECRET=RANDOM_STRING
NEXTAUTH_URL=http://localhost:3000
<<<<<<< HEAD
# This should always be localhost:3000 (or whatever port your app is running on)
NEXTAUTH_URL_INTERNAL=http://localhost:3000
=======
>>>>>>> 7ab8b81 (add basic react package)
DATABASE_URL='postgresql://postgres:postgres@postgres:5432/snoopforms?schema=public'
################
# Mail Setup #
################
# Necessary if email verification and password reset are enabled.
# See optional configurations below if you want to disable these features.
# MAIL_FROM=noreply@example.com
# SMTP_HOST=localhost
# SMTP_PORT=1025
# SMTP_SECURE_ENABLED=0 # Enable for TLS (port 465)
# SMTP_USER=smtpUser
# SMTP_PASSWORD=smtpPassword
########################################################################
# ------------------------------ OPTIONAL -----------------------------#
########################################################################
# Uncomment the variables you would like to use and customize the values.
#####################
# Disable Features #
#####################
# Email Verification. If you enable Email Verification you have to setup SMTP-Settings, too.
<<<<<<< HEAD
NEXT_PUBLIC_EMAIL_VERIFICATION_DISABLED=1
# Password Reset. If you enable Password Reset functionality you have to setup SMTP-Settings, too.
NEXT_PUBLIC_PASSWORD_RESET_DISABLED=1
# Signup. Disable the ability for new users to create an account.
# NEXT_PUBLIC_SIGNUP_DISABLED=1
=======
EMAIL_VERIFICATION_DISABLED=1
# Password Reset. If you enable Password Reset functionality you have to setup SMTP-Settings, too.
PASSWORD_RESET_DISABLED=1
>>>>>>> 7ab8b81 (add basic react package)
#######################
# Additional Options #
#######################
<<<<<<< HEAD
# NEXT_PUBLIC_TERMS_URL=https://www.example.com/terms
# NEXT_PUBLIC_PRIVACY_URL=https://www.example.com/privacy
# NEXT_PUBLIC_IMPRINT_URL=https://www.example.com/imprint
# NEXT_PUBLIC_PRIVACY_URL=https://www.example.com/enduserPrivacy
=======
# TERMS_URL=https://www.example.com/terms
# PRIVACY_URL=https://www.example.com/privacy
# PUBLIC_IMPRINT_URL=https://www.example.com/imprint
# PUBLIC_PRIVACY_URL=https://www.example.com/enduserPrivacy
>>>>>>> 7ab8b81 (add basic react package)
######################
# Posthog Tracking #
######################
# POSTHOG_API_HOST=https://app.posthog.com
# POSTHOG_API_KEY=<YOUR POSTHOG API KEY>
###############
# Telemetry #
###############
# We also track anononymous usage telemetry on the server to improve snoopForms.
# That way we can see how many people use snoopForms.
# We can't identify you, or your users and only receive the number of submissions on your instance.
# You help us a lot, if you leave this activated.
# If you still want to opt-out, uncomment the next line.
# TELEMETRY_DISABLED=1

View File

@@ -1,9 +1,84 @@
# Modify this variables according to your setup and needs
SECRET=RANDOM_STRING
DATABASE_URL='postgresql://user@localhost:5432/snoopforms?schema=public'
NEXTAUTH_URL=http://localhost:3000
ADMIN_EMAIL=user@example.com
ADMIN_PASSWORD='admin123'
########################################################################
# ------------ MANDATORY (CHANGE ACCORDING TO YOUR SETUP) ------------#
########################################################################
# For Docker Setup use this Database URL:
############
# Basics #
############
NEXTAUTH_SECRET=RANDOM_STRING
# Set this to your public-facing URL, e.g., https://example.com
NEXTAUTH_URL=http://localhost:3000
<<<<<<< HEAD
# This should always be localhost:3000 (or whatever port your app is running on)
NEXTAUTH_URL_INTERNAL=http://localhost:3000
=======
>>>>>>> 7ab8b81 (add basic react package)
DATABASE_URL='postgresql://postgres:postgres@localhost:5432/snoopforms?schema=public'
# For Docker Compose Production Setup use this Database URL:
# DATABASE_URL='postgresql://postgres:postgres@postgres:5432/snoopforms?schema=public'
################
# Mail Setup #
################
# Necessary if email verification and password reset are enabled.
# See optional configurations below if you want to disable these features.
MAIL_FROM=noreply@example.com
SMTP_HOST=localhost
SMTP_PORT=1025
SMTP_SECURE_ENABLED=0 # Enable for TLS (port 465)
SMTP_USER=smtpUser
SMTP_PASSWORD=smtpPassword
########################################################################
# ------------------------------ OPTIONAL -----------------------------#
########################################################################
# Uncomment the variables you would like to use and customize the values.
#####################
# Disable Features #
#####################
# Email Verification. If you enable Email Verification you have to setup SMTP-Settings, too.
# NEXT_PUBLIC_EMAIL_VERIFICATION_DISABLED=1
# Password Reset. If you enable Password Reset functionality you have to setup SMTP-Settings, too.
# NEXT_PUBLIC_PASSWORD_RESET_DISABLED=1
# Signup. Disable the ability for new users to create an account.
# NEXT_PUBLIC_SIGNUP_DISABLED=1
#######################
# Additional Options #
#######################
# NEXT_PUBLIC_TERMS_URL=https://www.example.com/terms
# NEXT_PUBLIC_PRIVACY_URL=https://www.example.com/privacy
# NEXT_PUBLIC_IMPRINT_URL=https://www.example.com/imprint
# NEXT_PUBLIC_PRIVACY_URL=https://www.example.com/enduserPrivacy
######################
# Posthog Tracking #
######################
# POSTHOG_API_HOST=https://app.posthog.com
# POSTHOG_API_KEY=<YOUR POSTHOG API KEY>
###############
# Telemetry #
###############
# We also track anononymous usage telemetry on the server to improve snoopForms.
# That way we can see how many people use snoopForms.
# We can't identify you, or your users and only receive the number of submissions on your instance.
# You help us a lot, if you leave this activated.
# If you still want to opt-out, uncomment the next line.
# TELEMETRY_DISABLED=1

10
.eslintrc.js Normal file
View File

@@ -0,0 +1,10 @@
module.exports = {
root: true,
// This tells ESLint to load the config from the package `eslint-config-formbricks`
extends: ["formbricks"],
settings: {
next: {
rootDir: ["apps/*/"],
},
},
};

View File

@@ -1,3 +0,0 @@
{
"extends": "next/core-web-vitals"
}

44
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@@ -0,0 +1,44 @@
---
name: Bug report
about: Something not working as expected? Let us look into it
title: ""
labels: bug
assignees: ""
---
Found a bug? Please fill out the sections below. 👍
### Issue Summary
<!--
A summary of the issue. This needs to be a clear detailed-rich summary.
-->
(Write your answer here.)
### Steps to Reproduce
<!--
1. (for example) Went to ...
2. Clicked on...
3. ...
Any other relevant information. For example, why do you consider this a bug and what did you expect to happen instead?
-->
(Write your answer here.)
## Environment
- [ ] snoopForms Cloud (app.snoopforms.com)
- [ ] self-hosted snoopForms, version/commit: [please provide]
### Additional Context
<!--
- Browser version, screen recording, console logs, network requests: You can make a recording with [Bird Eats Bug](https://birdeatsbug.com/).
- Node.js version
- Anything else that you think could be an issue.
-->
(Write your answer here.)

5
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: Questions
url: https://discord.gg/3YFcABF2Ts
about: Ask a general question about the project on our Discord server

View File

@@ -0,0 +1,41 @@
---
name: Feature request
about: Suggest a feature or idea
title: ""
labels: feature
assignees: ""
---
### Is your proposal related to a problem?
<!--
Provide a clear and concise description of what the problem is.
For example, "I'm always frustrated when..."
-->
(Write your answer here.)
### Describe the solution you'd like
<!--
Provide a clear and concise description of what you want to happen.
-->
(Describe your proposed solution here.)
### Describe alternatives you've considered
<!--
Let us know about other solutions you've tried or researched.
-->
(Write your answer here.)
### Additional context
<!--
Is there anything else you can add about the proposal?
You might want to link to related issues here, if you haven't already.
-->
(Write your answer here.)

36
.github/workflows/checks.yml vendored Normal file
View File

@@ -0,0 +1,36 @@
name: Checks
on: [push]
jobs:
build:
name: Build, lint, and test on Node ${{ matrix.node }} and ${{ matrix.os }}
runs-on: ${{ matrix.os }}
strategy:
matrix:
node: ["16.x"]
os: [ubuntu-latest]
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
steps:
- name: Checkout repo
uses: actions/checkout@v2
- name: Use Node ${{ matrix.node }}
uses: actions/setup-node@v1
with:
node-version: ${{ matrix.node }}
- name: Install pnpm
uses: pnpm/action-setup@v2.2.2
- name: Install dependencies
run: pnpm install
- name: Lint
run: pnpm lint
- name: Build
run: pnpm build

40
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,40 @@
name: Release
on:
push:
branches:
- main
concurrency: ${{ github.workflow }}-${{ github.ref }}
jobs:
release:
name: Release
runs-on: ubuntu-latest
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
steps:
- name: Checkout Repo
uses: actions/checkout@v2
- name: Setup Node.js 16.x
uses: actions/setup-node@v2
with:
node-version: 16.x
- name: Install pnpm
uses: pnpm/action-setup@v2.2.2
- name: Install Dependencies
run: pnpm install
- name: Create Release Pull Request or Publish to npm
id: changesets
uses: changesets/action@v1
with:
# This expects you to have a script called release which does a build for your packages and calls changeset publish
publish: pnpm release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

25
.gitignore vendored
View File

@@ -1,19 +1,21 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
node_modules
.pnp
.pnp.js
.pnpm-store/
# testing
/coverage
coverage
# next.js
/.next/
/out/
.next/
out/
build
# production
/build
# node
dist/
# misc
.DS_Store
@@ -23,7 +25,6 @@
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# local env files
.env
@@ -31,9 +32,7 @@ yarn-error.log*
.env.development.local
.env.test.local
.env.production.local
!packages/database/.env
# vercel
.vercel
# typescript
*.tsbuildinfo
# turbo
.turbo

6
.npmrc Normal file
View File

@@ -0,0 +1,6 @@
auto-install-peers=true
link-workspace-packages = true
shamefully-hoist = true
shared-workspace-shrinkwrap = true
access = public
enable-pre-post-scripts = true

1
.prettierrc.js Normal file
View File

@@ -0,0 +1 @@
module.exports = require("./packages/prettier-config/prettier-preset");

5
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,5 @@
{
"recommendations": [
"ms-vscode-remote.remote-containers"
]
}

28
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,28 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Next.js: debug server-side",
"type": "node-terminal",
"request": "launch",
"command": "yarn dev"
},
{
"name": "Next.js: debug client-side",
"type": "chrome",
"request": "launch",
"url": "http://localhost:3000"
},
{
"name": "Next.js: debug full stack",
"type": "node-terminal",
"request": "launch",
"command": "yarn dev",
"serverReadyAction": {
"pattern": "started server on .+, url: (https?://.+)",
"uriFormat": "%s",
"action": "debugWithChrome"
}
}
]
}

27
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,27 @@
There are many ways to contribute to snoopForms.
# Creating a PR
Please fork the repository, make your changes and create a new pull request if you want to make an update.
If you want to speak to us before doing lots of work, please join our [Discord server](https://snoopforms.com/discord) and tell us what you would like to work on - we're very responsive and friendly!
For QA of your Pull-Request, you can also get in touch with Matti on Discord. But we will also get to your PR without you taking additional action ;-)
# Issues
Spotted a bug? Has deployment gone wrong? Do you have user feedback? Raise an issue for the fastest response.
... or pick up and fix an issue if you want to do a Pull Request.
# Feature requests
Raise an issue for these and tag it as an Enhancement. We love every idea. Please give us as much context on the why as possible.
# Features
We are currently working on having a clear [Roadmap](https://github.com/orgs/formbricks/projects/1) for the next steps ahead.
But you can also pick a feature that is not already on the roadmap if you think it creates a positive impact for snoopForms.
If you are at all unsure, just raise it as an enhancement issue first and tell us that you like to work on it, and we'll very quickly respond.

View File

@@ -1,40 +0,0 @@
# Install dependencies only when needed
FROM node:16-alpine AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile
# Rebuild the source code only when needed
FROM node:16-alpine AS builder
WORKDIR /app
COPY . .
COPY --from=deps /app/node_modules ./node_modules
RUN yarn prisma generate
RUN yarn tsc prisma/seed.ts
RUN yarn build && yarn install --production --ignore-scripts --prefer-offline
# Production image, copy all the files and run next
FROM node:16-alpine AS runner
WORKDIR /app
ENV NODE_ENV production
RUN addgroup -g 1001 -S nodejs
RUN adduser -S nextjs -u 1001
COPY --from=builder /app/next.config.js ./
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next ./.next
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/prisma ./prisma
USER nextjs
EXPOSE 3000
ENV NEXT_TELEMETRY_DISABLED 1
CMD ["yarn", "start"]

View File

111
README.md
View File

@@ -1,39 +1,41 @@
<p align="center">
<a href="https://github.com/snoopForms/snoopforms">
<a href="https://github.com/formbricks/snoopforms">
<img src="https://user-images.githubusercontent.com/72809645/172191504-808da997-025b-4b1f-90c0-b8ef658af2dd.svg" alt="Logo" width="500">
</a>
<h3 align="center">snoopForms</h3>
<p align="center">
The Open-Source Typeform Alternative
Finally, good open-source forms!
<br />
<a href="https://snoopforms.com/">Website</a> | <a href="https://discord.gg/3YFcABF2Ts">Join Discord community</a>
<a href="https://snoopforms.com/">Website & Hosted version</a> | <a href="https://snoopforms.com/discord">Join Discord community</a>
</p>
</p>
<p align="center">
<a href="https://github.com/snoopForms/snoopforms/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-AGPLv3-purple" alt="License"></a> <a href="https://discord.gg/3YFcABF2Ts"><img src="https://img.shields.io/badge/Discord-SnoopForms-%234A154B" alt="Join snoopForms Discord"></a>
<a href="https://github.com/formbricks/snoopforms/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-AGPLv3-purple" alt="License"></a> <a href="https://discord.gg/3YFcABF2Ts"><img src="https://img.shields.io/discord/979077669410979880?label=Discord&logo=discord&logoColor=%23fff" alt="Join snoopForms Discord"></a> <a href="https://github.com/formbricks/snoopforms/stargazers"><img src="https://img.shields.io/github/stars/snoopForms/snoopforms?logo=github" alt="Github Stars"></a>
<a href="https://news.ycombinator.com/item?id=32303986"><img src="https://img.shields.io/badge/Hacker%20News-122-%23FF6600" alt="Hacker News"></a>
<a href="https://www.producthunt.com/products/snoopforms"><img src="https://img.shields.io/badge/Product%20Hunt-%232%20Product%20of%20the%20Day-orange?logo=producthunt&logoColor=%23fff" alt="Product Hunt"></a>
</p>
<br/>
> :warning: **Note**: This repository is still in an early stage of development. We love the open source community and want to show what we are working on early. We will update this readme with more information once it is safe to use. Until then, feel free to share your thoughts, contact us, and contribute if you'd like.
<br/>
## About snoopForms
<img width="937" alt="screenshot-snoopForms" src="https://user-images.githubusercontent.com/675065/172094334-b5ca09d0-2058-42e3-9b05-75c79c098d06.svg">
<img width="937" alt="snoopForms-architecture" src="https://user-images.githubusercontent.com/675065/182550268-09794c9e-1187-470e-b795-697ceb2a93b8.svg">
Spin up forms in minutes. Pipe your data exactly where you need it. Maximize your results with juicy analytics.
## What is snoopForms?
With snoopForms you can build complex multi-page forms in minutes using either our built-in No Code Builder or our [React library](https://github.com/formbricks/snoopforms-react). All form submissions are automatically sent to the snoopForms platform for processing and analysis. You can view the submission within the platform or you can easily configure pipelines to send your data to other systems, services or databases.
### Features
- Work with the React Lib or use our No Code Builder to build exactly the forms you need.
- Pipe your data where you need it. Dont wait for your form provider to finally build the integration you desperately need.
- Since you can self-host Snoop Forms, its 100% compliant with all privacy regulations.
- How users interact with your form can be as important as their input. Dont miss anything with our best-in-class analytics.
- We aim for the best possible developer experience. Use what you like, build on top what you need. Everything is possible.
- React Lib & No Code Builder to build & integrate forms rapidly.
- 100% compliant with all privacy regulations (self-hosted).
- (next) Put your data to work with integrations.
- (next) Juicy analytics out of the box.
- (always) smooth Developer Experience comes first.
### Built With
@@ -43,81 +45,114 @@ Spin up forms in minutes. Pipe your data exactly where you need it. Maximize you
- [TailwindCSS](https://tailwindcss.com/)
- [Prisma](https://prisma.io/)
## Getting started
## Cloud vs. self-hosted
We offer you a ready hosted and maintained version of snoopForms on [snoopforms.com](https://snoopforms.com). It is always up to date and offers a generous free plan. If you want to try snoopForms, or save yourself the hassle and stress of self-hosting, this is the place to start.
The version of snoopForms you'll find in this repository is the same version that runs in the cloud, and you can easily host it yourself on your servers. See the readme below for the deployment instructions.
(In the future we may develop additional features that aren't in the free Open-Source version)
## Get started with development
This repository is a monorepository using [Turborepo](https://turborepo.org/) and [pnpm](https://pnpm.io/). It contains the snoopForms [server application](https://github.com/formbricks/snoopforms/tree/main/apps/web), the [react library](https://github.com/formbricks/snoopforms/tree/main/packages/react) and other helper packages like database or UI library.
### How to run locally
To get the project running locally on your machine you need to have the following development tools installed:
- Node.JS (we recommend v16)
- Yarn
- PostgreSQL
- [pnpm](https://pnpm.io/)
- [Docker](https://www.docker.com/) (to run PostgreSQL / MailHog)
1. Clone the project:
```
git clone https://github.com/snoopForms/snoopforms.git && cd snoopforms
git clone https://github.com/formbricks/snoopforms.git
```
2. Install Node.JS packages:
and move into the directory
```
yarn install
cd snoopforms
```
3. Create a `.env` file based on `.env.example` and change it according to your setup. Make sure the `DATABASE_URL` variable is set correctly according to your local database.
2. Install Node.JS packages via pnpm. Don't have pnpm? Get it [here](https://pnpm.io/installation)
```
pnpm install
```
3. To make the process of installing a dev dependencies easier, we offer a [`docker-compose.yml`](https://docs.docker.com/compose/) with the following containers:
- a `postgres` container and environment variables preset to reach it,
- a `mailhog` container that acts as a mock SMTP server and shows received mails in a web UI (forwarded to your host's `localhost:8025`)
```
docker-compose -f docker-compose.dev.yml up -d
```
4. Create a `.env` file based on `.env.example` and change it according to your setup. If you are using a cloud based database or another mail server, you will need to update the `DATABASE_URL` and SMTP settings in your `.env` accordingly.
```
cp .env.example .env
```
4. Use the code editor of your choice to edit the .env file.
5. Make sure your PostgreSQL Database Server is running. Then let prisma set up the database for you:
```
yarn prisma migrate dev
pnpm dlx prisma migrate dev
```
6. Start the development server:
```
yarn dev
pnpm dev
```
**You can now access the app on [https://localhost:3000](https://localhost:3000)**
**You can now access the app on [https://localhost:3000](https://localhost:3000)**. You will be automatically redirected to the login. To use your local installation of snoopForms, create a new account.
## Deployment
For viewing the confirmation email and other emails the system sends you, you can access mailhog at [https://localhost:8025](https://localhost:8025)
The easiest way to deploy snoopForms yourself on your own machine is using Docker. This requires Docker and the docker compose plugin on your system to work.
## Deployment for Production Setup
The easiest way to deploy snoopForms on your own machine is using Docker. This requires Docker and the docker compose plugin on your system to work.
Clone the repository:
```
git clone https://github.com/snoopForms/snoopforms.git && cd snoopforms
git clone https://github.com/formbricks/snoopforms.git && cd snoopforms
```
Create a `.env` file based on `.env.example` and change it according to your setup.
<<<<<<< HEAD
Create a `.env` file based on `.env.docker` and change all fields according to your setup. This file comes with a basic setup and snoopForms 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_EMAIL_VERIFICATION_DISABLED=1`).
=======
Create a `.env` file based on `.env.docker` and change all fields according to your setup. This file comes with a basic setup and snoopForms 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 (`# EMAIL_VERIFICATION_DISABLED=1`) and password reset (`# PASSWORD_RESET_DISABLED=1`).
> > > > > > > 7ab8b81 (add basic react package)
Copy the `.env.docker` file to `.env` and edit it with an editor of your choice if needed.
```
cp .env.example .env && nano .env
cp .env.docker .env
```
Start the docker compose process to build and spin up the snoopForms container as well as the postgres database.
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.
```
Finally start the docker compose process to build and spin up the snoopForms container as well as the PostgreSQL database.
```bash
docker compose up -d
# (use docker-compose if you are on an older docker version)
```
You can now access the app on [https://localhost:3000](https://localhost:3000)
You can now access the app on [https://localhost:3000](https://localhost:3000). You will be automatically redirected to the login. To use your local installation of snoopForms, create a new account.
## Contributing

4
apps/web/.eslintrc.js Normal file
View File

@@ -0,0 +1,4 @@
module.exports = {
root: true,
extends: ["formbricks"],
};

40
apps/web/.gitignore vendored Normal file
View File

@@ -0,0 +1,40 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
.idea/
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# local env files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# vercel
.vercel
# typescript
*.tsbuildinfo

8
apps/web/CHANGELOG.md Normal file
View File

@@ -0,0 +1,8 @@
# web
## 1.0.1
### Patch Changes
- Updated dependencies [28b6410]
- @snoopforms/react@0.3.6

41
apps/web/Dockerfile Normal file
View File

@@ -0,0 +1,41 @@
# Add lockfile and package.json's of isolated subworkspace
FROM node:16-alpine AS installer
RUN apk update
RUN apk --no-cache add curl libc6-compat
RUN curl -fsSL "https://github.com/pnpm/pnpm/releases/latest/download/pnpm-linuxstatic-x64" -o /bin/pnpm; chmod +x /bin/pnpm;
WORKDIR /app
# First install the dependencies (as they change less often)
COPY . .
# Copy .env file because Docker don't follow symlinks
COPY .env /app/apps/web/
RUN pnpm install
# Build the project
RUN pnpm dlx prisma generate
RUN pnpm turbo run build --filter=web...
FROM node:16-alpine AS runner
RUN apk --no-cache add curl libc6-compat
RUN curl -fsSL "https://github.com/pnpm/pnpm/releases/latest/download/pnpm-linuxstatic-x64" -o /bin/pnpm; chmod +x /bin/pnpm;
# Don't run production as root
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
USER nextjs
WORKDIR /home/nextjs
COPY --from=installer /app/apps/web/next.config.js .
COPY --from=installer /app/apps/web/package.json .
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=installer --chown=nextjs:nodejs /app/apps/web/.next/standalone ./
COPY --from=installer --chown=nextjs:nodejs /app/apps/web/.next/static ./apps/web/.next/static
COPY --from=installer --chown=nextjs:nodejs /app/apps/web/public ./apps/web/public
COPY --from=installer --chown=nextjs:nodejs /app/packages/database/prisma ./packages/database/prisma
CMD pnpm dlx prisma migrate deploy && node apps/web/server.js

View File

@@ -1,11 +1,6 @@
import { Menu, Transition } from "@headlessui/react";
import {
DocumentAddIcon,
PlusIcon,
TerminalIcon,
ViewGridAddIcon,
} from "@heroicons/react/outline";
import { DotsHorizontalIcon, TrashIcon } from "@heroicons/react/solid";
import { DocumentPlusIcon, PlusIcon, CommandLineIcon, SquaresPlusIcon } from "@heroicons/react/24/outline";
import { EllipsisHorizontalIcon, TrashIcon } from "@heroicons/react/24/solid";
import Link from "next/link";
import { Fragment, useState } from "react";
import { useForms } from "../lib/forms";
@@ -37,7 +32,7 @@ export default function FormList() {
return (
<>
<div className="h-full py-8">
<div className="h-full px-6 py-8">
{forms &&
(forms.length === 0 ? (
<div className="mt-5 text-center">
@@ -47,18 +42,17 @@ export default function FormList() {
hintText="Start by creating a form."
buttonText="create form"
borderStyles="border-4 border-dotted border-red"
hasButton={true}
>
<DocumentAddIcon className="w-24 h-24 mx-auto text-ui-gray-medium stroke-thin" />
hasButton={true}>
<DocumentPlusIcon className="text-ui-gray-medium stroke-thin mx-auto h-24 w-24" />
</EmptyPageFiller>
</div>
) : (
<ul className="grid grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5 place-content-stretch ">
<ul className="grid grid-cols-2 place-content-stretch gap-6 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 ">
<button onClick={() => newForm()}>
<li className="col-span-1">
<div className="overflow-hidden font-light text-white rounded-md shadow bg-snoopfade">
<li className="col-span-1 h-56">
<div className="bg-snoopfade flex h-full items-center justify-center overflow-hidden rounded-md font-light text-white shadow">
<div className="px-4 py-8 sm:p-14">
<PlusIcon className="mx-auto w-14 h-14 stroke-thin" />
<PlusIcon className="stroke-thin mx-auto h-14 w-14" />
create form
</div>
</div>
@@ -67,48 +61,40 @@ export default function FormList() {
{forms
.sort((a, b) => b.updatedAt - a.updatedAt)
.map((form, formIdx) => (
<li key={form.id} className="relative col-span-1 ">
<div className="flex flex-col justify-between h-full bg-white rounded-md shadow">
<div className="px-4 py-5 text-lg sm:p-6">
{form.name}
<li key={form.id} className="relative col-span-1 h-56">
<div className="flex h-full flex-col justify-between rounded-md bg-white shadow">
<div className="p-6">
<p className="line-clamp-3 text-lg">{form.name}</p>
</div>
<Link href={`/forms/${form.id}`}>
<a className="absolute w-full h-full" />
<a className="absolute h-full w-full" />
</Link>
<div className="divide-y divide-ui-gray-light ">
<div className="inline-flex px-2 py-1 mb-2 ml-4 text-sm rounded-sm bg-ui-gray-light text-ui-gray-dark">
<div className="divide-ui-gray-light divide-y ">
<div className="bg-ui-gray-light text-ui-gray-dark mb-2 ml-4 inline-flex rounded-sm px-2 py-1 text-sm">
{form.formType == "NOCODE" ? (
<div className="flex">
<ViewGridAddIcon className="w-4 h-4 my-auto mr-1" />
<SquaresPlusIcon className="my-auto mr-1 h-4 w-4" />
No-Code
</div>
) : (
<div className="flex">
<TerminalIcon className="w-4 h-4 my-auto mr-1" />
<CommandLineIcon className="my-auto mr-1 h-4 w-4" />
Code
</div>
)}
</div>
<div className="flex justify-between px-4 py-2 text-right sm:px-6">
<p className="text-xs text-ui-gray-medium ">
<p className="text-ui-gray-medium text-xs ">
{form._count?.submissionSessions} responses
</p>
<Menu
as="div"
className="relative inline-block text-left"
>
<Menu as="div" className="relative z-10 inline-block text-left">
{({ open }) => (
<>
<div>
<Menu.Button className="flex items-center p-2 -m-2 rounded-full text-red">
<span className="sr-only">
Open options
</span>
<DotsHorizontalIcon
className="w-5 h-5"
aria-hidden="true"
/>
<Menu.Button className="text-red -m-2 flex items-center rounded-full p-2">
<span className="sr-only">Open options</span>
<EllipsisHorizontalIcon className="h-5 w-5" aria-hidden="true" />
</Menu.Button>
</div>
@@ -120,28 +106,23 @@ export default function FormList() {
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
leaveTo="transform opacity-0 scale-95">
<Menu.Items
static
className="absolute left-0 w-56 px-1 mt-2 origin-top-right bg-white rounded-sm shadow-lg"
>
className="absolute left-0 mt-2 w-56 origin-top-right rounded-sm bg-white px-1 shadow-lg">
<div className="py-1">
<Menu.Item>
{({ active }) => (
<button
onClick={() =>
deleteForm(form, formIdx)
}
onClick={() => deleteForm(form, formIdx)}
className={classNames(
active
? "bg-ui-gray-light rounded-sm text-ui-black"
? "bg-ui-gray-light text-ui-black rounded-sm"
: "text-ui-gray-dark",
"flex px-4 py-2 text-sm w-full"
)}
>
"flex w-full px-4 py-2 text-sm"
)}>
<TrashIcon
className="w-5 h-5 mr-3 text-ui-gray-dark"
className="text-ui-gray-dark mr-3 h-5 w-5"
aria-hidden="true"
/>
<span>Delete Form</span>

View File

@@ -0,0 +1,16 @@
import { TailSpin } from "react-loader-spinner";
export default function Loading() {
return (
<div className="min-h-screen bg-white px-4 py-16 sm:px-6 sm:py-24 md:grid md:place-items-center lg:px-8">
<div className="mx-auto max-w-max">
<main>
<div className="flex justify-center">
<TailSpin color="#1f2937" height={30} width={30} />
</div>
<p className="text-ui-gray-dark mt-5 text-sm">Loading...</p>
</main>
</div>
</div>
);
}

View File

@@ -10,9 +10,8 @@ export default function LoadingModal({ isLoading }) {
static
className="fixed inset-0 z-10 overflow-y-auto"
open={isLoading}
onClose={() => {}}
>
<div className="flex items-end justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
onClose={() => {}}>
<div className="flex min-h-screen items-end justify-center px-4 pt-4 pb-20 text-center sm:block sm:p-0">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
@@ -20,16 +19,12 @@ export default function LoadingModal({ isLoading }) {
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Dialog.Overlay className="fixed inset-0 transition-opacity bg-gray-500 bg-opacity-20" />
leaveTo="opacity-0">
<Dialog.Overlay className="fixed inset-0 bg-gray-500 bg-opacity-20 transition-opacity" />
</Transition.Child>
{/* This element is to trick the browser into centering the modal contents. */}
<span
className="hidden sm:inline-block sm:align-middle sm:h-screen"
aria-hidden="true"
>
<span className="hidden sm:inline-block sm:h-screen sm:align-middle" aria-hidden="true">
&#8203;
</span>
<Transition.Child
@@ -39,9 +34,8 @@ export default function LoadingModal({ isLoading }) {
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<div className="inline-flex items-center justify-center px-4 py-20 pb-4 overflow-hidden text-left align-bottom transition-all transform rounded-lg sm:my-8 sm:align-middle sm:max-w-sm sm:w-full sm:p-6">
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95">
<div className="inline-flex transform items-center justify-center overflow-hidden rounded-lg px-4 py-20 pb-4 text-left align-bottom transition-all sm:my-8 sm:w-full sm:max-w-sm sm:p-6 sm:align-middle">
<TailSpin color="#000" height={50} width={50} />
</div>
</Transition.Child>

View File

@@ -0,0 +1,11 @@
export default function MessagePage({ text }) {
return (
<div className="min-h-screen bg-white px-4 py-16 sm:px-6 sm:py-24 md:grid md:place-items-center lg:px-8">
<div className="mx-auto max-w-max">
<main>
<div className="text-ui-gray-dark flex justify-center text-sm">{text}</div>
</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,49 @@
/* This example requires Tailwind CSS v2.0+ */
import { Dialog, Transition } from "@headlessui/react";
import { XMarkIcon } from "@heroicons/react/24/outline";
import { Fragment } from "react";
export default function Modal({ open, setOpen, children }) {
return (
<Transition.Root show={open} as={Fragment}>
<Dialog as="div" className="relative z-10" onClose={setOpen}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0">
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto">
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95">
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-white px-4 pt-5 pb-4 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-xl sm:p-6 lg:max-w-3xl">
<div className="absolute top-0 right-0 hidden pt-4 pr-4 sm:block">
<button
type="button"
className="rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2"
onClick={() => setOpen(false)}>
<span className="sr-only">Close</span>
<XMarkIcon className="h-6 w-6" aria-hidden="true" />
</button>
</div>
<div className="flex-col sm:flex sm:items-start">{children}</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
}

View File

@@ -26,7 +26,7 @@ const StandardButton: React.FC<Props> = ({
return (
<button
className={classNames(
`inline-flex items-center rounded shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500`,
`inline-flex items-center rounded shadow-sm focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2`,
disabled ? "disabled:opacity-50" : "",
fullwidth ? " w-full justify-center " : "",
small ? "px-2.5 py-1.5 text-xs" : "px-5 py-3 text-sm",
@@ -35,8 +35,7 @@ const StandardButton: React.FC<Props> = ({
)}
onClick={onClick}
disabled={disabled}
{...rest}
>
{...rest}>
{children}
</button>
);

View File

@@ -0,0 +1,138 @@
import EditorJS from "@editorjs/editorjs";
import {
CogIcon,
DocumentPlusIcon,
EyeIcon,
PaperAirplaneIcon,
ShareIcon,
} from "@heroicons/react/24/outline";
import dynamic from "next/dynamic";
import { useRouter } from "next/router";
import { useRef, useState } from "react";
import { toast } from "react-toastify";
import { useForm } from "../../lib/forms";
import { persistNoCodeForm, useNoCodeForm } from "../../lib/noCodeForm";
import LimitedWidth from "../layout/LimitedWidth";
import SecondNavBar from "../layout/SecondNavBar";
import Loading from "../Loading";
import LoadingModal from "../LoadingModal";
import ShareModal from "./ShareModal";
import SettingsModal from "./SettingsModal";
let Editor = dynamic(() => import("../editorjs/Editor"), {
ssr: false,
});
/* import Editor from "../editorjs/Editor"; */
export default function Builder({ formId }) {
const router = useRouter();
const editorRef = useRef<EditorJS | null>();
const { isLoadingForm } = useForm(formId);
const { noCodeForm, isLoadingNoCodeForm, mutateNoCodeForm } = useNoCodeForm(formId);
const [openShareModal, setOpenShareModal] = useState(false);
const [openSettingsModal, setOpenSettingsModal] = useState(false);
const [loading, setLoading] = useState(false);
const addPage = () => {
editorRef.current.blocks.insert("pageTransition", {
submitLabel: "Submit",
});
const block = editorRef.current.blocks.insert("paragraph");
editorRef.current.caret.setToBlock(editorRef.current.blocks.getBlockIndex(block.id));
};
const initAction = async (editor: EditorJS) => {
editor.blocks.insert("header", {
text: noCodeForm.form.name,
});
const focusBlock = editor.blocks.insert("textQuestion");
editor.blocks.insert("pageTransition", {
submitLabel: "Submit",
});
editor.blocks.insert("header", {
text: "Thank you",
});
editor.blocks.insert("paragraph", {
text: "Thanks a lot for your time and insights 🙏",
});
editor.blocks.delete(0); // remove defaultBlock
editor.caret.setToBlock(editorRef.current.blocks.getBlockIndex(focusBlock.id));
};
const publishChanges = async () => {
setLoading(true);
setTimeout(async () => {
const newNoCodeForm = JSON.parse(JSON.stringify(noCodeForm));
const firstPublish = newNoCodeForm.published ? false : true;
newNoCodeForm.blocks = newNoCodeForm.blocksDraft;
newNoCodeForm.published = true;
await persistNoCodeForm(newNoCodeForm);
mutateNoCodeForm(newNoCodeForm);
setLoading(false);
toast(firstPublish ? "Your form is now published 🎉" : "Your changes are now published 🎉");
}, 500);
};
const noCodeSecondNavigation = [
{
id: "addPage",
onClick: () => addPage(),
Icon: DocumentPlusIcon,
//Icon: PlusIcon
label: "Page",
},
{
id: "preview",
onClick: () => {
router.push(`/forms/${formId}/preview`);
},
Icon: EyeIcon,
label: "Preview",
},
{
id: "publish",
onClick: () => publishChanges(),
Icon: PaperAirplaneIcon,
label: "Publish",
},
{
id: "share",
onClick: () => setOpenShareModal(true),
Icon: ShareIcon,
label: "Share",
},
{
id: "settings",
onClick: () => setOpenSettingsModal(true),
Icon: CogIcon,
label: "Settings",
},
];
if (isLoadingNoCodeForm || isLoadingForm) {
return <Loading />;
}
return (
<>
<SecondNavBar navItems={noCodeSecondNavigation} />
<div className="mb-20 h-full w-full overflow-auto bg-white">
<div className="flex w-full justify-center pt-10 pb-56">
<LimitedWidth>
{Editor && (
<Editor
id="editor"
formId={formId}
editorRef={editorRef}
autofocus={true}
initAction={initAction}
/>
)}
</LimitedWidth>
</div>
</div>
<ShareModal open={openShareModal} setOpen={setOpenShareModal} formId={formId} />
<SettingsModal open={openSettingsModal} setOpen={setOpenSettingsModal} formId={formId} />
<LoadingModal isLoading={loading} />
</>
);
}

View File

@@ -1,14 +1,9 @@
/* This example requires Tailwind CSS v2.0+ */
import { TrashIcon } from "@heroicons/react/solid";
import { TrashIcon } from "@heroicons/react/24/solid";
import { MdWavingHand } from "react-icons/md";
import { classNames } from "../../lib/utils";
export default function PageToolbar({
page,
pageIdx,
deletePageAction,
setPageType,
}) {
export default function PageToolbar({ page, pageIdx, deletePageAction, setPageType }) {
return (
<div className="relative">
<div className="absolute inset-0 flex items-center" aria-hidden="true">
@@ -29,14 +24,13 @@ export default function PageToolbar({
page.type === "thankyou"
? "bg-red-400 text-white hover:bg-red-500"
: "bg-white text-gray-400 hover:bg-gray-50",
"has-tooltip relative inline-flex items-center px-4 py-2 text-sm font-medium border border-gray-300 rounded-l-md focus:z-10 focus:outline-none focus:ring-1 focus:ring-red-500 focus:border-red-500"
)}
>
"has-tooltip relative inline-flex items-center rounded-l-md border border-gray-300 px-4 py-2 text-sm font-medium focus:z-10 focus:border-red-500 focus:outline-none focus:ring-1 focus:ring-red-500"
)}>
<span className="sr-only">Thank You Page</span>
<span className="w-32 p-1 -mt-16 -ml-10 text-xs text-white bg-gray-600 rounded shadow-lg tooltip">
<span className="tooltip -mt-16 -ml-10 w-32 rounded bg-gray-600 p-1 text-xs text-white shadow-lg">
Is Thank You Page
</span>
<MdWavingHand className="w-4 h-4" aria-hidden="true" />
<MdWavingHand className="h-4 w-4" aria-hidden="true" />
</button>
<button
type="button"
@@ -45,13 +39,12 @@ export default function PageToolbar({
deletePageAction(pageIdx);
}
}}
className="relative inline-flex items-center px-4 py-2 text-sm font-medium text-gray-400 bg-white border border-gray-300 has-tooltip rounded-r-md hover:bg-gray-50 focus:z-10 focus:outline-none focus:ring-1 focus:ring-red-500 focus:border-red-500"
>
className="has-tooltip relative inline-flex items-center rounded-r-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-400 hover:bg-gray-50 focus:z-10 focus:border-red-500 focus:outline-none focus:ring-1 focus:ring-red-500">
<span className="sr-only">Delete</span>
<span className="w-24 p-1 -mt-16 -ml-8 text-xs text-white bg-gray-600 rounded shadow-lg tooltip">
<span className="tooltip -mt-16 -ml-8 w-24 rounded bg-gray-600 p-1 text-xs text-white shadow-lg">
Delete Page
</span>
<TrashIcon className="w-4 h-4" aria-hidden="true" />
<TrashIcon className="h-4 w-4" aria-hidden="true" />
</button>
</span>
</div>

View File

@@ -0,0 +1,115 @@
/* This example requires Tailwind CSS v2.0+ */
import { Dialog, Switch, Transition } from "@headlessui/react";
import { Fragment, useState } from "react";
import { TailSpin } from "react-loader-spinner";
import { persistNoCodeForm, useNoCodeForm } from "../../lib/noCodeForm";
import Loading from "../Loading";
import { XMarkIcon } from "@heroicons/react/24/outline";
import { toast } from "react-toastify";
import { classNames } from "../../lib/utils";
export default function SettingsModal({ open, setOpen, formId }) {
const { noCodeForm, isLoadingNoCodeForm, mutateNoCodeForm } = useNoCodeForm(formId);
const [loading, setLoading] = useState(false);
const toggleClose = async () => {
setLoading(true);
setTimeout(async () => {
const newNoCodeForm = JSON.parse(JSON.stringify(noCodeForm));
newNoCodeForm.closed = !noCodeForm.closed;
await persistNoCodeForm(newNoCodeForm);
mutateNoCodeForm(newNoCodeForm);
setLoading(false);
toast(
newNoCodeForm.closed
? "Your form is now closed for submissions "
: "Your form is now open for submissions 🎉"
);
}, 500);
};
if (isLoadingNoCodeForm) {
return <Loading />;
}
return (
<Transition.Root show={open} as={Fragment}>
<Dialog as="div" className="relative z-10" onClose={setOpen}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0">
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto">
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95">
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-white px-4 pt-5 pb-4 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-4xl sm:p-6">
<div className="absolute top-0 right-0 hidden pt-4 pr-4 sm:block">
<button
type="button"
className="rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2"
onClick={() => setOpen(false)}>
<span className="sr-only">Close</span>
<XMarkIcon className="h-6 w-6" aria-hidden="true" />
</button>
</div>
<div className="px-4 py-5 sm:p-6">
<div className="mb-4">
<h1 className="text-2xl font-medium leading-6 text-gray-900">Settings</h1>
</div>
<h3 className="text-lg font-medium leading-6 text-gray-900">Access</h3>
<div className="mt-2 w-full text-sm text-gray-500">
<Switch.Group as="div" className="flex w-full items-center justify-between">
<span className="flex flex-grow flex-col">
<Switch.Label as="span" className="text-sm font-medium text-gray-900" passive={true}>
Close form for new submissions?
</Switch.Label>
<Switch.Description as="span" className="text-sm text-gray-500">
Your form is currently{" "}
<span className="font-bold">{noCodeForm.closed ? "closed" : "open"}</span> for
submissions.
</Switch.Description>
</span>
{loading ? (
<TailSpin color="#1f2937" height={30} width={30} />
) : (
<Switch
checked={noCodeForm.closed}
onChange={() => toggleClose()}
className={classNames(
noCodeForm.closed ? "bg-red-600" : "bg-gray-200",
"relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2"
)}>
<span
aria-hidden="true"
className={classNames(
noCodeForm.closed ? "translate-x-5" : "translate-x-0",
"pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
)}
/>
</Switch>
)}
</Switch.Group>
</div>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
}

View File

@@ -1,6 +1,7 @@
/* This example requires Tailwind CSS v2.0+ */
import { Dialog, Transition } from "@headlessui/react";
import { InformationCircleIcon, XIcon } from "@heroicons/react/outline";
import { InformationCircleIcon, XMarkIcon } from "@heroicons/react/24/outline";
import { toast } from "react-toastify";
import { Fragment } from "react";
import { useNoCodeForm } from "../../lib/noCodeForm";
import Loading from "../Loading";
@@ -28,13 +29,12 @@ export default function ShareModal({ open, setOpen, formId }) {
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 transition-opacity bg-gray-500 bg-opacity-75" />
leaveTo="opacity-0">
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto">
<div className="flex items-end justify-center min-h-full p-4 text-center sm:items-center sm:p-0">
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
@@ -42,47 +42,36 @@ export default function ShareModal({ open, setOpen, formId }) {
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative px-4 pt-5 pb-4 overflow-hidden text-left transition-all transform bg-white rounded-lg shadow-xl sm:my-8 sm:max-w-4xl sm:w-full sm:p-6">
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95">
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-white px-4 pt-5 pb-4 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-4xl sm:p-6">
<div className="absolute top-0 right-0 hidden pt-4 pr-4 sm:block">
<button
type="button"
className="text-gray-400 bg-white rounded-md hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"
onClick={() => setOpen(false)}
>
className="rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2"
onClick={() => setOpen(false)}>
<span className="sr-only">Close</span>
<XIcon className="w-6 h-6" aria-hidden="true" />
<XMarkIcon className="h-6 w-6" aria-hidden="true" />
</button>
</div>
{!noCodeForm.published ? (
<div className="p-4 border border-gray-700 rounded-md bg-ui-gray-light">
<div className="bg-ui-gray-light rounded-md border border-gray-700 p-4">
<div className="flex">
<div className="flex-shrink-0">
<InformationCircleIcon
className="w-5 h-5 text-blue-400"
aria-hidden="true"
/>
<InformationCircleIcon className="h-5 w-5 text-blue-400" aria-hidden="true" />
</div>
<div className="flex-1 ml-3 md:flex md:justify-between">
<div className="ml-3 flex-1 md:flex md:justify-between">
<p className="text-sm text-gray-700">
You haven&apos;t published this form yet. Please
publish this form to share it with others and get the
first submissions.
You haven&apos;t published this form yet. Please publish this form to share it with
others and get the first submissions.
</p>
</div>
</div>
</div>
) : (
<div className="px-4 py-5 sm:p-6">
<h3 className="text-lg font-medium leading-6 text-gray-900">
Share your form
</h3>
<div className="max-w-xl mt-2 text-sm text-gray-500">
<p>
Let your participants fill out your form by accessing it
via the public link.
</p>
<h3 className="text-lg font-medium leading-6 text-gray-900">Share your form</h3>
<div className="mt-2 max-w-xl text-sm text-gray-500">
<p>Let your participants fill out your form by accessing it via the public link.</p>
</div>
<div className="mt-5 sm:flex sm:items-center">
<div className="w-full sm:max-w-xs">
@@ -93,7 +82,7 @@ export default function ShareModal({ open, setOpen, formId }) {
id="surveyLink"
type="text"
placeholder="Enter your email"
className="block w-full border-gray-300 rounded-md shadow-sm focus:ring-red-500 focus:border-red-500 sm:text-sm"
className="block w-full rounded-md border-gray-300 shadow-sm focus:border-red-500 focus:ring-red-500 sm:text-sm"
value={getPublicFormUrl()}
disabled
/>
@@ -101,9 +90,9 @@ export default function ShareModal({ open, setOpen, formId }) {
<button
onClick={() => {
navigator.clipboard.writeText(getPublicFormUrl());
toast("Link copied to clipboard 🙌");
}}
className="inline-flex items-center justify-center w-full px-4 py-2 mt-3 font-medium text-white bg-gray-800 border border-transparent rounded-md shadow-sm hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
>
className="mt-3 inline-flex w-full items-center justify-center rounded-md border border-transparent bg-gray-800 px-4 py-2 font-medium text-white shadow-sm hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm">
Copy
</button>
</div>

View File

@@ -0,0 +1,124 @@
/* eslint-disable react-hooks/exhaustive-deps */
import EditorJS from "@editorjs/editorjs";
import Header from "@editorjs/header";
import Paragraph from "@editorjs/paragraph";
import DragDrop from "editorjs-drag-drop";
import Undo from "editorjs-undo";
import { Fragment, useCallback, useEffect } from "react";
import { toast } from "react-toastify";
import { persistNoCodeForm, useNoCodeForm } from "../../lib/noCodeForm";
import Loading from "../Loading";
import EmailQuestion from "./tools/EmailQuestion";
import PageTransition from "./tools/PageTransition";
import MultipleChoiceQuestion from "./tools/MultipleChoiceQuestion";
import TextQuestion from "./tools/TextQuestion";
import WebsiteQuestion from "./tools/WebsiteQuestion";
import PhoneQuestion from "./tools/PhoneQuestion";
import NumberQuestion from "./tools/NumberQuestion";
import TextareaQuestion from "./tools/TextareaQuestion";
interface EditorProps {
id: string;
autofocus: boolean;
editorRef: { current: EditorJS | null };
formId: string;
initAction: (editor: EditorJS) => void;
}
const Editor = ({ id, autofocus = false, editorRef, formId, initAction }: EditorProps) => {
const { noCodeForm, isLoadingNoCodeForm, mutateNoCodeForm } = useNoCodeForm(formId);
const keyPressListener = useCallback((e) => {
if (e.key === "s" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
toast("snoopForms autosaves your work ✌️");
}
}, []);
useEffect(() => {
window.addEventListener("keydown", keyPressListener);
// Remove event listeners on cleanup
return () => {
window.removeEventListener("keydown", keyPressListener);
};
}, [keyPressListener]);
// This will run only once
useEffect(() => {
if (!isLoadingNoCodeForm) {
if (!editorRef.current) {
initEditor();
}
}
return () => {
destroyEditor();
};
async function destroyEditor() {
await editorRef.current.isReady;
editorRef.current.destroy();
editorRef.current = null;
}
}, [isLoadingNoCodeForm]);
const initEditor = () => {
const editor = new EditorJS({
minHeight: 0,
holder: id,
data: { blocks: noCodeForm.blocksDraft },
onReady: () => {
editorRef.current = editor;
new DragDrop(editor);
new Undo({ editor });
if (editor.blocks.getBlocksCount() === 1) {
initAction(editor);
}
},
onChange: async () => {
let content = await editor.saver.save();
const newNoCodeForm = JSON.parse(JSON.stringify(noCodeForm));
newNoCodeForm.blocksDraft = content.blocks;
await persistNoCodeForm(newNoCodeForm);
mutateNoCodeForm(newNoCodeForm);
},
autofocus: autofocus,
defaultBlock: "paragraph",
tools: {
textQuestion: TextQuestion,
textareaQuestion: TextareaQuestion,
emailQuestion: EmailQuestion,
multipleChoiceQuestion: MultipleChoiceQuestion,
numberQuestion: NumberQuestion,
phoneQuestion: PhoneQuestion,
websiteQuestion: WebsiteQuestion,
pageTransition: PageTransition,
paragraph: {
class: Paragraph,
inlineToolbar: true,
config: {
placeholder: "Start with your content or hit tab-key to insert block",
},
},
header: {
class: Header,
config: {
placeholder: "Enter a header",
levels: [1, 2, 3],
defaultLevel: 1,
},
},
},
});
};
if (isLoadingNoCodeForm) {
return <Loading />;
}
return (
<Fragment>
<div id={id} />
</Fragment>
);
};
export default Editor;

View File

@@ -0,0 +1,102 @@
import { API, BlockTool, BlockToolData, ToolConfig } from "@editorjs/editorjs";
import ReactDOM from "react-dom";
import EmailQuestionComponent from "./EmailQuestionComponent";
export interface EmailQuestionData extends BlockToolData {
label: string;
help: string;
placeholder: string;
required: boolean;
}
export default class EmailQuestion implements BlockTool {
settings: { name: string; icon: string }[];
api: API;
data: EmailQuestionData;
nodes: { holder: HTMLElement };
config: ToolConfig;
static get toolbox(): { icon: string; title?: string } {
return {
icon: `<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M14.243 5.757a6 6 0 10-.986 9.284 1 1 0 111.087 1.678A8 8 0 1118 10a3 3 0 01-4.8 2.401A4 4 0 1114 10a1 1 0 102 0c0-1.537-.586-3.07-1.757-4.243zM12 10a2 2 0 10-4 0 2 2 0 004 0z" clip-rule="evenodd" />
</svg>`,
title: "Email Question",
};
}
constructor({ api, config, data }: { api: API; config?: ToolConfig; data?: EmailQuestionData }) {
this.api = api;
this.config = config;
this.settings = [
{
name: "required",
icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 512" class="w-3 h-3"><path d="M471.99 334.43L336.06 256l135.93-78.43c7.66-4.42 10.28-14.2 5.86-21.86l-32.02-55.43c-4.42-7.65-14.21-10.28-21.87-5.86l-135.93 78.43V16c0-8.84-7.17-16-16.01-16h-64.04c-8.84 0-16.01 7.16-16.01 16v156.86L56.04 94.43c-7.66-4.42-17.45-1.79-21.87 5.86L2.15 155.71c-4.42 7.65-1.8 17.44 5.86 21.86L143.94 256 8.01 334.43c-7.66 4.42-10.28 14.21-5.86 21.86l32.02 55.43c4.42 7.65 14.21 10.27 21.87 5.86l135.93-78.43V496c0 8.84 7.17 16 16.01 16h64.04c8.84 0 16.01-7.16 16.01-16V339.14l135.93 78.43c7.66 4.42 17.45 1.8 21.87-5.86l32.02-55.43c4.42-7.65 1.8-17.43-5.86-21.85z"/></svg>`,
},
];
this.data = {
label: data.label || "",
help: data.help || "",
placeholder: data.placeholder || "your email",
required: data.required || false,
};
this.nodes = {
holder: null,
};
}
renderSettings(): HTMLElement {
const wrapper = document.createElement("div");
this.settings.forEach((tune) => {
let button = document.createElement("div");
button.classList.add("cdx-settings-button");
button.classList.toggle("cdx-settings-button--active", this.data[tune.name]);
button.innerHTML = tune.icon;
wrapper.appendChild(button);
button.addEventListener("click", () => {
this._toggleTune(tune.name);
button.classList.toggle("cdx-settings-button--active");
});
});
return wrapper;
}
/**
* @private
* Click on the Settings Button
* @param {string} tune — tune name from this.settings
*/
_toggleTune(tune: string) {
if (tune === "required") {
this.data.required = !this.data.required;
}
}
render(): HTMLElement {
const rootNode = document.createElement("div");
this.nodes.holder = rootNode;
const onDataChange = (newData: EmailQuestionData) => {
this.data = {
...newData,
};
};
ReactDOM.render(<EmailQuestionComponent onDataChange={onDataChange} data={this.data} />, rootNode);
return this.nodes.holder;
}
save() {
return this.data;
}
}

View File

@@ -0,0 +1,84 @@
/* eslint-disable react-hooks/exhaustive-deps */
import { EnvelopeIcon } from "@heroicons/react/24/solid";
import { default as React } from "react";
import { type EmailQuestionData } from "./EmailQuestion";
const DEFAULT_INITIAL_DATA = () => {
return {
label: "",
placeholder: "",
help: "",
required: false,
};
};
type Props = {
data: EmailQuestionData;
onDataChange: (newData: EmailQuestionData) => void;
};
const EmailQuestionComponent = (props: Props) => {
const [data, setData] = React.useState(props.data ? props.data : DEFAULT_INITIAL_DATA);
const updateData = (newData: EmailQuestionData) => {
setData(newData);
if (props.onDataChange) {
props.onDataChange(newData);
}
};
const onInputChange = (fieldName: string) => {
return (e: React.FormEvent<HTMLInputElement>) => {
const newData = {
...data,
};
newData[fieldName] = e.currentTarget.value;
updateData(newData);
};
};
return (
<div className="pb-5">
<div className="text-md relative font-bold leading-7 text-gray-800 sm:truncate">
<input
type="text"
id="label"
defaultValue={data.label}
className="w-full border-0 border-transparent p-0 ring-0 placeholder:text-gray-300 focus:ring-0"
placeholder="Your Question"
onChange={onInputChange("label")}
/>
{data.required && (
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3 text-red-500">
*
</div>
)}
</div>
<div className="relative mt-1 max-w-sm rounded-md shadow-sm">
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<EnvelopeIcon className="h-5 w-5 text-gray-400" aria-hidden="true" />
</div>
<input
type="text"
name="email"
className="block w-full rounded-md border-gray-300 pl-10 text-gray-300 sm:text-sm"
defaultValue={data.placeholder}
onChange={onInputChange("placeholder")}
/>
</div>
<input
type="text"
id="help-text"
defaultValue={data.help}
className="mt-2 block w-full max-w-sm border-0 border-transparent p-0 text-sm font-light text-gray-500 ring-0 placeholder:text-gray-300 focus:ring-0"
placeholder="optional help text"
onChange={onInputChange("help")}
/>
</div>
);
};
export default EmailQuestionComponent;

View File

@@ -0,0 +1,129 @@
import { API, BlockTool, BlockToolData, ToolConfig } from "@editorjs/editorjs";
import { default as React } from "react";
import ReactDOM from "react-dom";
import MultipleChoiceQuestionComponent from "./MultipleChoiceQuestionComponent";
interface MultipleChoiceQuestionData extends BlockToolData {
label: string;
help: string;
options: string[];
required: boolean;
}
export default class MultipleChoiceQuestion implements BlockTool {
settings: { name: string; icon: string }[];
api: API;
config: ToolConfig;
data: any;
readOnly: boolean;
block: any;
wrapper: undefined | HTMLElement;
nodes: { holder: HTMLElement };
static get toolbox(): { icon: string; title?: string } {
return {
icon: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="16" height="16" viewBox="0 0 16 16">
<path fill="#000000" d="M8 0c-4.418 0-8 3.582-8 8s3.582 8 8 8 8-3.582 8-8-3.582-8-8-8zM8 14c-3.314 0-6-2.686-6-6s2.686-6 6-6c3.314 0 6 2.686 6 6s-2.686 6-6 6zM5 8c0-1.657 1.343-3 3-3s3 1.343 3 3c0 1.657-1.343 3-3 3s-3-1.343-3-3z"/>
</svg>`,
title: "Multiple Choice Question",
};
}
static get isReadOnlySupported() {
return true;
}
constructor({
data,
config,
api,
readOnly,
}: {
api: API;
config?: ToolConfig;
data?: MultipleChoiceQuestionData;
block?: any;
readOnly: boolean;
}) {
this.api = api;
this.config = config;
this.readOnly = readOnly;
this.data = {
label: data.label || "",
help: data.help || "",
options: data.options || [],
required: data.required || false,
multipleChoice: data.multipleChoice || false,
};
this.settings = [
{
name: "required",
icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 512" class="w-3 h-3"><!-- Font Awesome Pro 5.15.4 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) --><path d="M471.99 334.43L336.06 256l135.93-78.43c7.66-4.42 10.28-14.2 5.86-21.86l-32.02-55.43c-4.42-7.65-14.21-10.28-21.87-5.86l-135.93 78.43V16c0-8.84-7.17-16-16.01-16h-64.04c-8.84 0-16.01 7.16-16.01 16v156.86L56.04 94.43c-7.66-4.42-17.45-1.79-21.87 5.86L2.15 155.71c-4.42 7.65-1.8 17.44 5.86 21.86L143.94 256 8.01 334.43c-7.66 4.42-10.28 14.21-5.86 21.86l32.02 55.43c4.42 7.65 14.21 10.27 21.87 5.86l135.93-78.43V496c0 8.84 7.17 16 16.01 16h64.04c8.84 0 16.01-7.16 16.01-16V339.14l135.93 78.43c7.66 4.42 17.45 1.8 21.87-5.86l32.02-55.43c4.42-7.65 1.8-17.43-5.86-21.85z"/></svg>`,
},
];
this.nodes = {
holder: null,
};
}
renderSettings(): HTMLElement {
const wrapper = document.createElement("div");
this.settings.forEach((tune) => {
let button = document.createElement("div");
button.classList.add("cdx-settings-button");
button.classList.toggle("cdx-settings-button--active", this.data[tune.name]);
button.innerHTML = tune.icon;
wrapper.appendChild(button);
button.addEventListener("click", () => {
this._toggleTune(tune.name);
button.classList.toggle("cdx-settings-button--active");
});
});
return wrapper;
}
/**
* @private
* Click on the Settings Button
* @param {string} tune — tune name from this.settings
*/
_toggleTune(tune) {
//this.wrapper.classList.toggle(tune.name, !!this.data[tune.name]);
if (tune === "required") {
this.data.required = !this.data.required;
}
}
render() {
const rootNode = document.createElement("div");
//rootNode.setAttribute("class", this.CSS.wrapper);
this.nodes.holder = rootNode;
const onDataChange = (newData) => {
this.data = {
...newData,
};
};
ReactDOM.render(
<MultipleChoiceQuestionComponent
onDataChange={onDataChange}
readOnly={this.readOnly}
data={this.data}
/>,
rootNode
);
return this.nodes.holder;
}
save() {
return this.data;
}
}

View File

@@ -0,0 +1,166 @@
/* eslint-disable react-hooks/exhaustive-deps */
import { Switch } from "@headlessui/react";
import { TrashIcon } from "@heroicons/react/24/solid";
import { default as React } from "react";
import { v4 as uuidv4 } from "uuid";
import { classNames } from "../../../lib/utils";
const DEFAULT_INITIAL_DATA = () => {
return {
label: "",
help: "",
required: false,
multipleChoice: false,
options: [
{
id: uuidv4(),
label: "",
},
],
};
};
const SingleChoiceQuestion = (props) => {
const [choiceData, setChoiceData] = React.useState(
props.data.options.length > 0 ? props.data : DEFAULT_INITIAL_DATA
);
const updateData = (newData) => {
setChoiceData(newData);
if (props.onDataChange) {
// Inform editorjs about data change
props.onDataChange(newData);
}
};
const onAddOption = () => {
const newData = {
...choiceData,
};
newData.options.push({
id: uuidv4(),
label: "",
});
updateData(newData);
};
const onDeleteOption = (optionIdx) => {
const newData = {
...choiceData,
};
newData.options.splice(optionIdx, 1);
updateData(newData);
};
const onInputChange = (fieldName) => {
return (e) => {
const newData = {
...choiceData,
};
newData[fieldName] = e.currentTarget.value;
updateData(newData);
};
};
const onOptionChange = (index, fieldName) => {
return (e) => {
const newData = {
...choiceData,
};
newData.options[index][fieldName] = e.currentTarget.value;
updateData(newData);
};
};
return (
<div className="pb-5">
<div className="text-md relative font-bold leading-7 text-gray-800 sm:truncate">
<input
type="text"
id="label"
defaultValue={choiceData.label}
onBlur={onInputChange("label")}
className="w-full border-0 border-transparent p-0 ring-0 placeholder:text-gray-300 focus:ring-0"
placeholder="Your Question"
/>
{choiceData.required && (
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3 text-red-500">
*
</div>
)}
</div>
<div className="mt-2 space-y-2">
{choiceData.options.map((option, optionIdx) => (
<div key={option.label} className="relative flex items-start pr-2 hover:rounded hover:bg-gray-50">
<span className="flex w-full items-center text-sm ">
<span
className={classNames(
choiceData.multipleChoice ? "rounded-sm" : "rounded-full",
"flex h-4 w-4 items-center justify-center border border-gray-300"
)}
aria-hidden="true">
<span className="h-1.5 w-1.5 rounded-full" />
</span>
<input
type="text"
defaultValue={option.label}
onBlur={onOptionChange(optionIdx, "label")}
className="ml-3 w-full border-0 border-transparent bg-transparent p-0 font-medium text-gray-900 outline-none placeholder:text-gray-300 focus:outline-none focus:ring-0"
placeholder={`Option ${optionIdx + 1}`}
/>
{optionIdx !== 0 && (
<button onClick={() => onDeleteOption(optionIdx)} className="right-3 pl-4">
<TrashIcon className="h-4 w-4 text-gray-300" />
</button>
)}
</span>
</div>
))}
</div>
<input
type="text"
id="help-text"
defaultValue={choiceData.help}
onBlur={onInputChange("help")}
className="mt-2 block w-full max-w-sm border-0 border-transparent p-0 text-sm font-light text-gray-500 ring-0 placeholder:text-gray-300 focus:ring-0"
placeholder="optional help text"
/>
<div className="relative z-0 mt-2 flex divide-x divide-gray-200">
<button
className="mr-3 mt-2 inline-flex items-center justify-center rounded border border-gray-300 bg-white px-2.5 py-1.5 text-xs font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none"
onClick={onAddOption}>
Add option
</button>
<Switch.Group as="div" className="flex items-center pl-3">
<Switch
checked={choiceData.multipleChoice}
onChange={() => {
const newData = {
...choiceData,
};
newData.multipleChoice = !newData.multipleChoice;
updateData(newData);
}}
className={classNames(
choiceData.multipleChoice ? "bg-red-600" : "bg-gray-200",
"relative inline-flex h-4 w-7 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2"
)}>
<span
aria-hidden="true"
className={classNames(
choiceData.multipleChoice ? "translate-x-3" : "translate-x-0",
"pointer-events-none inline-block h-3 w-3 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
)}
/>
</Switch>
<Switch.Label as="span" className="ml-3">
<span className="text-sm font-medium text-gray-700">Multiple Selection </span>
{/* <span className="text-sm text-gray-500">(Save 10%)</span> */}
</Switch.Label>
</Switch.Group>
</div>
</div>
);
};
export default SingleChoiceQuestion;

View File

@@ -0,0 +1,120 @@
import { API, BlockTool, BlockToolData, ToolConfig } from "@editorjs/editorjs";
import ReactDOM from "react-dom";
//styles imports in angular.json
interface NumberQuestionData extends BlockToolData {
label: string;
help: string;
placeholder: string;
required: boolean;
}
export default class NumberQuestion implements BlockTool {
settings: { name: string; icon: string }[];
api: API;
data: any;
wrapper: undefined | HTMLElement;
static get toolbox(): { icon: string; title?: string } {
return {
icon: `<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M9.243 3.03a1 1 0 01.727 1.213L9.53 6h2.94l.56-2.243a1 1 0 111.94.486L14.53 6H17a1 1 0 110 2h-2.97l-1 4H15a1 1 0 110 2h-2.47l-.56 2.242a1 1 0 11-1.94-.485L10.47 14H7.53l-.56 2.242a1 1 0 11-1.94-.485L5.47 14H3a1 1 0 110-2h2.97l1-4H5a1 1 0 110-2h2.47l.56-2.243a1 1 0 011.213-.727zM9.03 8l-1 4h2.938l1-4H9.031z" clip-rule="evenodd" />
</svg>`,
title: "Number Question",
};
}
constructor({ data }: { api: API; config?: ToolConfig; data?: NumberQuestionData }) {
this.wrapper = undefined;
this.settings = [
{
name: "required",
icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 512" class="w-3 h-3"><!-- Font Awesome Pro 5.15.4 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) --><path d="M471.99 334.43L336.06 256l135.93-78.43c7.66-4.42 10.28-14.2 5.86-21.86l-32.02-55.43c-4.42-7.65-14.21-10.28-21.87-5.86l-135.93 78.43V16c0-8.84-7.17-16-16.01-16h-64.04c-8.84 0-16.01 7.16-16.01 16v156.86L56.04 94.43c-7.66-4.42-17.45-1.79-21.87 5.86L2.15 155.71c-4.42 7.65-1.8 17.44 5.86 21.86L143.94 256 8.01 334.43c-7.66 4.42-10.28 14.21-5.86 21.86l32.02 55.43c4.42 7.65 14.21 10.27 21.87 5.86l135.93-78.43V496c0 8.84 7.17 16 16.01 16h64.04c8.84 0 16.01-7.16 16.01-16V339.14l135.93 78.43c7.66 4.42 17.45 1.8 21.87-5.86l32.02-55.43c4.42-7.65 1.8-17.43-5.86-21.85z"/></svg>`,
},
];
this.data = {
label: data.label || "",
help: data.help || "",
placeholder: data.placeholder || "",
required: data.required !== undefined ? data.required : true,
};
}
save(block: HTMLDivElement) {
return {
...this.data,
label: (block.firstElementChild.firstElementChild.firstElementChild as HTMLInputElement).value,
placeholder: (block.firstElementChild.childNodes[1] as HTMLInputElement).value,
help: (block.firstElementChild.lastElementChild as HTMLInputElement).value,
};
}
renderSettings(): HTMLElement {
const wrapper = document.createElement("div");
this.settings.forEach((tune) => {
let button = document.createElement("div");
button.classList.add("cdx-settings-button");
button.classList.toggle("cdx-settings-button--active", this.data[tune.name]);
button.innerHTML = tune.icon;
wrapper.appendChild(button);
button.addEventListener("click", () => {
this._toggleTune(tune.name);
button.classList.toggle("cdx-settings-button--active");
});
});
return wrapper;
}
/**
* @private
* Click on the Settings Button
* @param {string} tune — tune name from this.settings
*/
_toggleTune(tune) {
this.wrapper.classList.toggle(tune.name, !!this.data[tune.name]);
if (tune === "required") {
this.data.required = !this.data.required;
this.wrapper.childNodes[0].childNodes[0].childNodes[1].textContent = this.data.required ? "*" : "";
}
}
render(): HTMLElement {
this.wrapper = document.createElement("div");
const toolView = (
<div className="pb-5">
<div className="text-md relative font-bold leading-7 text-gray-800 sm:truncate">
<input
type="text"
id="label"
defaultValue={this.data.label}
className="w-full border-0 border-transparent p-0 ring-0 placeholder:text-gray-300 focus:ring-0"
placeholder="Your Question"
/>
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3 text-red-500">
*
</div>
</div>
<input
type="text"
className="mt-1 block w-full max-w-sm rounded-md border-gray-300 text-sm text-gray-400 shadow-sm placeholder:text-gray-300"
placeholder="optional placeholder"
defaultValue={this.data.placeholder}
/>
<input
type="text"
id="help-text"
defaultValue={this.data.help}
className="mt-2 block w-full max-w-sm border-0 border-transparent p-0 text-sm font-light text-gray-500 ring-0 placeholder:text-gray-300 focus:ring-0"
placeholder="optional help text"
/>
</div>
);
ReactDOM.render(toolView, this.wrapper);
return this.wrapper;
}
}

View File

@@ -0,0 +1,59 @@
import { API, BlockTool, BlockToolData, ToolConfig } from "@editorjs/editorjs";
import ReactDOM from "react-dom";
//styles imports in angular.json
interface PageTransitionData extends BlockToolData {
submitLabel: string;
}
export default class PageTransition implements BlockTool {
submitLabel: string;
placeholder: string;
api: API;
/* static get toolbox(): { icon: string; title?: string } {
return {
icon: `<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"> <path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" /> </svg>`,
title: "New Page",
};
} */
constructor({ data, api }: { api: API; config?: ToolConfig; data?: PageTransitionData }) {
this.api = api;
this.submitLabel = data.submitLabel || "Submit";
}
save(block: HTMLDivElement) {
return {
submitLabel: (block.firstElementChild.firstElementChild.firstElementChild as HTMLElement).innerHTML,
};
}
render(): HTMLElement {
const container = document.createElement("div");
const toolView = (
<div className="relative mt-16 mb-8">
<div className="left absolute -top-14 inline-flex items-center rounded-md border border-transparent bg-gray-700 px-4 py-3 text-sm font-medium text-white shadow-sm hover:bg-gray-900 focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2">
<div
contentEditable
id="label"
defaultValue={this.submitLabel}
className="border-transparent bg-transparent p-0 ring-0 placeholder:text-opacity-5 focus:border-transparent focus:outline-none focus:ring-0 active:ring-0">
{this.submitLabel}
</div>
{/* <ArrowRightIcon className="w-5 h-5 ml-2 -mr-1" aria-hidden="true" /> */}
</div>
<div className="relative my-4">
<div className="absolute inset-0 flex items-center" aria-hidden="true">
<div className="w-full border-t border-gray-300" />
</div>
<div className="relative flex justify-center">
<span className="bg-white px-2 text-sm text-gray-500">Next Page</span>
</div>
</div>
</div>
);
ReactDOM.render(toolView, container);
return container;
}
}

View File

@@ -0,0 +1,128 @@
import { API, BlockTool, BlockToolData, ToolConfig } from "@editorjs/editorjs";
import { PhoneIcon } from "@heroicons/react/24/solid";
import ReactDOM from "react-dom";
//styles imports in angular.json
interface PhoneQuestionData extends BlockToolData {
label: string;
help: string;
placeholder: string;
required: boolean;
}
export default class PhoneQuestion implements BlockTool {
settings: { name: string; icon: string }[];
api: API;
data: any;
wrapper: undefined | HTMLElement;
static get toolbox(): { icon: string; title?: string } {
return {
icon: `<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path d="M2 3a1 1 0 011-1h2.153a1 1 0 01.986.836l.74 4.435a1 1 0 01-.54 1.06l-1.548.773a11.037 11.037 0 006.105 6.105l.774-1.548a1 1 0 011.059-.54l4.435.74a1 1 0 01.836.986V17a1 1 0 01-1 1h-2C7.82 18 2 12.18 2 5V3z" />
</svg>`,
title: "Phone Question",
};
}
constructor({ data }: { api: API; config?: ToolConfig; data?: PhoneQuestionData }) {
this.wrapper = undefined;
this.settings = [
{
name: "required",
icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 512" class="w-3 h-3"><path d="M471.99 334.43L336.06 256l135.93-78.43c7.66-4.42 10.28-14.2 5.86-21.86l-32.02-55.43c-4.42-7.65-14.21-10.28-21.87-5.86l-135.93 78.43V16c0-8.84-7.17-16-16.01-16h-64.04c-8.84 0-16.01 7.16-16.01 16v156.86L56.04 94.43c-7.66-4.42-17.45-1.79-21.87 5.86L2.15 155.71c-4.42 7.65-1.8 17.44 5.86 21.86L143.94 256 8.01 334.43c-7.66 4.42-10.28 14.21-5.86 21.86l32.02 55.43c4.42 7.65 14.21 10.27 21.87 5.86l135.93-78.43V496c0 8.84 7.17 16 16.01 16h64.04c8.84 0 16.01-7.16 16.01-16V339.14l135.93 78.43c7.66 4.42 17.45 1.8 21.87-5.86l32.02-55.43c4.42-7.65 1.8-17.43-5.86-21.85z"/></svg>`,
},
];
this.data = {
label: data.label || "",
help: data.help || "",
placeholder: data.placeholder || "+1 (555) 987-6543",
required: data.required !== undefined ? data.required : true,
};
}
save(block: HTMLDivElement) {
return {
...this.data,
label: (block.firstElementChild.firstElementChild.firstElementChild as HTMLInputElement).value,
placeholder: (
(block.firstElementChild.childNodes[1] as HTMLInputElement).lastElementChild as HTMLInputElement
).value,
help: (block.firstElementChild.lastElementChild as HTMLInputElement).value,
};
}
renderSettings(): HTMLElement {
const wrapper = document.createElement("div");
this.settings.forEach((tune) => {
let button = document.createElement("div");
button.classList.add("cdx-settings-button");
button.classList.toggle("cdx-settings-button--active", this.data[tune.name]);
button.innerHTML = tune.icon;
wrapper.appendChild(button);
button.addEventListener("click", () => {
this._toggleTune(tune.name);
button.classList.toggle("cdx-settings-button--active");
});
});
return wrapper;
}
/**
* @private
* Click on the Settings Button
* @param {string} tune — tune name from this.settings
*/
_toggleTune(tune) {
this.wrapper.classList.toggle(tune.name, !!this.data[tune.name]);
if (tune === "required") {
this.data.required = !this.data.required;
this.wrapper.childNodes[0].childNodes[0].childNodes[1].textContent = this.data.required ? "*" : "";
}
}
render(): HTMLElement {
this.wrapper = document.createElement("div");
const toolView = (
<div className="pb-5">
<div className="text-md relative font-bold leading-7 text-gray-800 sm:truncate">
<input
type="text"
id="label"
defaultValue={this.data.label}
className="w-full border-0 border-transparent p-0 ring-0 placeholder:text-gray-300 focus:ring-0"
placeholder="Your Question"
/>
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3 text-red-500">
*
</div>
</div>
<div className="relative mt-1 max-w-sm rounded-md shadow-sm">
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<PhoneIcon className="h-5 w-5 text-gray-400" aria-hidden="true" />
</div>
<input
type="text"
name="website"
className="block w-full rounded-md border-gray-300 pl-10 text-gray-300 sm:text-sm"
defaultValue={this.data.placeholder}
/>
</div>
<input
type="text"
id="help-text"
defaultValue={this.data.help}
className="mt-2 block w-full max-w-sm border-0 border-transparent p-0 text-sm font-light text-gray-500 ring-0 placeholder:text-gray-300 focus:ring-0"
placeholder="optional help text"
/>
</div>
);
ReactDOM.render(toolView, this.wrapper);
return this.wrapper;
}
}

View File

@@ -0,0 +1,118 @@
import { API, BlockTool, BlockToolData, ToolConfig } from "@editorjs/editorjs";
import ReactDOM from "react-dom";
//styles imports in angular.json
interface TextQuestionData extends BlockToolData {
label: string;
help: string;
placeholder: string;
required: boolean;
}
export default class TextQuestion implements BlockTool {
settings: { name: string; icon: string }[];
api: API;
data: any;
wrapper: undefined | HTMLElement;
static get toolbox(): { icon: string; title?: string } {
return {
icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6"> <path stroke-linecap="round" stroke-linejoin="round" d="M3.75 9h16.5m-16.5 6.75h16.5" /> </svg>`,
title: "Text Question",
};
}
constructor({ data }: { api: API; config?: ToolConfig; data?: TextQuestionData }) {
this.wrapper = undefined;
this.settings = [
{
name: "required",
icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6"> <path stroke-linecap="round" stroke-linejoin="round" d="M3.75 9h16.5m-16.5 6.75h16.5" /> </svg>`,
},
];
this.data = {
label: data.label || "",
help: data.help || "",
placeholder: data.placeholder || "",
required: data.required !== undefined ? data.required : true,
};
}
save(block: HTMLDivElement) {
return {
...this.data,
label: (block.firstElementChild.firstElementChild.firstElementChild as HTMLInputElement).value,
placeholder: (block.firstElementChild.childNodes[1] as HTMLInputElement).value,
help: (block.firstElementChild.lastElementChild as HTMLInputElement).value,
};
}
renderSettings(): HTMLElement {
const wrapper = document.createElement("div");
this.settings.forEach((tune) => {
let button = document.createElement("div");
button.classList.add("cdx-settings-button");
button.classList.toggle("cdx-settings-button--active", this.data[tune.name]);
button.innerHTML = tune.icon;
wrapper.appendChild(button);
button.addEventListener("click", () => {
this._toggleTune(tune.name);
button.classList.toggle("cdx-settings-button--active");
});
});
return wrapper;
}
/**
* @private
* Click on the Settings Button
* @param {string} tune — tune name from this.settings
*/
_toggleTune(tune) {
this.wrapper.classList.toggle(tune.name, !!this.data[tune.name]);
if (tune === "required") {
this.data.required = !this.data.required;
this.wrapper.childNodes[0].childNodes[0].childNodes[1].textContent = this.data.required ? "*" : "";
}
}
render(): HTMLElement {
this.wrapper = document.createElement("div");
const toolView = (
<div className="pb-5">
<div className="text-md relative font-bold leading-7 text-gray-800 sm:truncate">
<input
type="text"
id="label"
defaultValue={this.data.label}
className="w-full border-0 border-transparent p-0 ring-0 placeholder:text-gray-300 focus:ring-0"
placeholder="Your Question"
/>
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3 text-red-500">
*
</div>
</div>
<input
type="text"
className="mt-1 block w-full max-w-sm rounded-md border-gray-300 text-sm text-gray-400 shadow-sm placeholder:text-gray-300"
placeholder="optional placeholder"
defaultValue={this.data.placeholder}
/>
<input
type="text"
id="help-text"
defaultValue={this.data.help}
className="mt-2 block w-full max-w-sm border-0 border-transparent p-0 text-sm font-light text-gray-500 ring-0 placeholder:text-gray-300 focus:ring-0"
placeholder="optional help text"
/>
</div>
);
ReactDOM.render(toolView, this.wrapper);
return this.wrapper;
}
}

View File

@@ -0,0 +1,118 @@
import { API, BlockTool, BlockToolData, ToolConfig } from "@editorjs/editorjs";
import ReactDOM from "react-dom";
//styles imports in angular.json
interface TextareaQuestionData extends BlockToolData {
label: string;
help: string;
placeholder: string;
required: boolean;
}
export default class TextareaQuestion implements BlockTool {
settings: { name: string; icon: string }[];
api: API;
data: any;
wrapper: undefined | HTMLElement;
static get toolbox(): { icon: string; title?: string } {
return {
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-align-justify"><line x1="21" y1="10" x2="3" y2="10"></line><line x1="21" y1="6" x2="3" y2="6"></line><line x1="21" y1="14" x2="3" y2="14"></line><line x1="21" y1="18" x2="3" y2="18"></line></svg>`,
title: "Long Text Question",
};
}
constructor({ data }: { api: API; config?: ToolConfig; data?: TextareaQuestionData }) {
this.wrapper = undefined;
this.settings = [
{
name: "required",
icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 512" class="w-3 h-3"><!-- Font Awesome Pro 5.15.4 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) --><path d="M471.99 334.43L336.06 256l135.93-78.43c7.66-4.42 10.28-14.2 5.86-21.86l-32.02-55.43c-4.42-7.65-14.21-10.28-21.87-5.86l-135.93 78.43V16c0-8.84-7.17-16-16.01-16h-64.04c-8.84 0-16.01 7.16-16.01 16v156.86L56.04 94.43c-7.66-4.42-17.45-1.79-21.87 5.86L2.15 155.71c-4.42 7.65-1.8 17.44 5.86 21.86L143.94 256 8.01 334.43c-7.66 4.42-10.28 14.21-5.86 21.86l32.02 55.43c4.42 7.65 14.21 10.27 21.87 5.86l135.93-78.43V496c0 8.84 7.17 16 16.01 16h64.04c8.84 0 16.01-7.16 16.01-16V339.14l135.93 78.43c7.66 4.42 17.45 1.8 21.87-5.86l32.02-55.43c4.42-7.65 1.8-17.43-5.86-21.85z"/></svg>`,
},
];
this.data = {
label: data.label || "",
help: data.help || "",
placeholder: data.placeholder || "",
required: data.required !== undefined ? data.required : true,
};
}
save(block: HTMLDivElement) {
return {
...this.data,
label: (block.firstElementChild.firstElementChild.firstElementChild as HTMLInputElement).value,
placeholder: (block.firstElementChild.childNodes[1] as HTMLInputElement).value,
help: (block.firstElementChild.lastElementChild as HTMLInputElement).value,
};
}
renderSettings(): HTMLElement {
const wrapper = document.createElement("div");
this.settings.forEach((tune) => {
let button = document.createElement("div");
button.classList.add("cdx-settings-button");
button.classList.toggle("cdx-settings-button--active", this.data[tune.name]);
button.innerHTML = tune.icon;
wrapper.appendChild(button);
button.addEventListener("click", () => {
this._toggleTune(tune.name);
button.classList.toggle("cdx-settings-button--active");
});
});
return wrapper;
}
/**
* @private
* Click on the Settings Button
* @param {string} tune — tune name from this.settings
*/
_toggleTune(tune) {
this.wrapper.classList.toggle(tune.name, !!this.data[tune.name]);
if (tune === "required") {
this.data.required = !this.data.required;
this.wrapper.childNodes[0].childNodes[0].childNodes[1].textContent = this.data.required ? "*" : "";
}
}
render(): HTMLElement {
this.wrapper = document.createElement("div");
const toolView = (
<div className="pb-5">
<div className="text-md relative font-bold leading-7 text-gray-800 sm:truncate">
<input
type="text"
id="label"
defaultValue={this.data.label}
className="w-full border-0 border-transparent p-0 ring-0 placeholder:text-gray-300 focus:ring-0"
placeholder="Your Question"
/>
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3 text-red-500">
*
</div>
</div>
<textarea
rows={4}
className="mt-1 block w-full max-w-sm rounded-md border-gray-300 text-sm text-gray-400 shadow-sm placeholder:text-gray-300"
placeholder="optional placeholder"
defaultValue={this.data.placeholder}
/>
<input
type="text"
id="help-text"
defaultValue={this.data.help}
className="mt-2 block w-full max-w-sm border-0 border-transparent p-0 text-sm font-light text-gray-500 ring-0 placeholder:text-gray-300 focus:ring-0"
placeholder="optional help text"
/>
</div>
);
ReactDOM.render(toolView, this.wrapper);
return this.wrapper;
}
}

View File

@@ -0,0 +1,128 @@
import { API, BlockTool, BlockToolData, ToolConfig } from "@editorjs/editorjs";
import { GlobeAltIcon } from "@heroicons/react/24/solid";
import ReactDOM from "react-dom";
//styles imports in angular.json
interface WebsiteQuestionData extends BlockToolData {
label: string;
help: string;
placeholder: string;
required: boolean;
}
export default class WebsiteQuestion implements BlockTool {
settings: { name: string; icon: string }[];
api: API;
data: any;
wrapper: undefined | HTMLElement;
static get toolbox(): { icon: string; title?: string } {
return {
icon: `<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M4.083 9h1.946c.089-1.546.383-2.97.837-4.118A6.004 6.004 0 004.083 9zM10 2a8 8 0 100 16 8 8 0 000-16zm0 2c-.076 0-.232.032-.465.262-.238.234-.497.623-.737 1.182-.389.907-.673 2.142-.766 3.556h3.936c-.093-1.414-.377-2.649-.766-3.556-.24-.56-.5-.948-.737-1.182C10.232 4.032 10.076 4 10 4zm3.971 5c-.089-1.546-.383-2.97-.837-4.118A6.004 6.004 0 0115.917 9h-1.946zm-2.003 2H8.032c.093 1.414.377 2.649.766 3.556.24.56.5.948.737 1.182.233.23.389.262.465.262.076 0 .232-.032.465-.262.238-.234.498-.623.737-1.182.389-.907.673-2.142.766-3.556zm1.166 4.118c.454-1.147.748-2.572.837-4.118h1.946a6.004 6.004 0 01-2.783 4.118zm-6.268 0C6.412 13.97 6.118 12.546 6.03 11H4.083a6.004 6.004 0 002.783 4.118z" clip-rule="evenodd" />
</svg>`,
title: "Website Question",
};
}
constructor({ data }: { api: API; config?: ToolConfig; data?: WebsiteQuestionData }) {
this.wrapper = undefined;
this.settings = [
{
name: "required",
icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 512" class="w-3 h-3"><path d="M471.99 334.43L336.06 256l135.93-78.43c7.66-4.42 10.28-14.2 5.86-21.86l-32.02-55.43c-4.42-7.65-14.21-10.28-21.87-5.86l-135.93 78.43V16c0-8.84-7.17-16-16.01-16h-64.04c-8.84 0-16.01 7.16-16.01 16v156.86L56.04 94.43c-7.66-4.42-17.45-1.79-21.87 5.86L2.15 155.71c-4.42 7.65-1.8 17.44 5.86 21.86L143.94 256 8.01 334.43c-7.66 4.42-10.28 14.21-5.86 21.86l32.02 55.43c4.42 7.65 14.21 10.27 21.87 5.86l135.93-78.43V496c0 8.84 7.17 16 16.01 16h64.04c8.84 0 16.01-7.16 16.01-16V339.14l135.93 78.43c7.66 4.42 17.45 1.8 21.87-5.86l32.02-55.43c4.42-7.65 1.8-17.43-5.86-21.85z"/></svg>`,
},
];
this.data = {
label: data.label || "",
help: data.help || "",
placeholder: data.placeholder || "https://",
required: data.required !== undefined ? data.required : true,
};
}
save(block: HTMLDivElement) {
return {
...this.data,
label: (block.firstElementChild.firstElementChild.firstElementChild as HTMLInputElement).value,
placeholder: (
(block.firstElementChild.childNodes[1] as HTMLInputElement).lastElementChild as HTMLInputElement
).value,
help: (block.firstElementChild.lastElementChild as HTMLInputElement).value,
};
}
renderSettings(): HTMLElement {
const wrapper = document.createElement("div");
this.settings.forEach((tune) => {
let button = document.createElement("div");
button.classList.add("cdx-settings-button");
button.classList.toggle("cdx-settings-button--active", this.data[tune.name]);
button.innerHTML = tune.icon;
wrapper.appendChild(button);
button.addEventListener("click", () => {
this._toggleTune(tune.name);
button.classList.toggle("cdx-settings-button--active");
});
});
return wrapper;
}
/**
* @private
* Click on the Settings Button
* @param {string} tune — tune name from this.settings
*/
_toggleTune(tune) {
this.wrapper.classList.toggle(tune.name, !!this.data[tune.name]);
if (tune === "required") {
this.data.required = !this.data.required;
this.wrapper.childNodes[0].childNodes[0].childNodes[1].textContent = this.data.required ? "*" : "";
}
}
render(): HTMLElement {
this.wrapper = document.createElement("div");
const toolView = (
<div className="pb-5">
<div className="text-md relative font-bold leading-7 text-gray-800 sm:truncate">
<input
type="text"
id="label"
defaultValue={this.data.label}
className="w-full border-0 border-transparent p-0 ring-0 placeholder:text-gray-300 focus:ring-0"
placeholder="Your Question"
/>
<div className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3 text-red-500">
*
</div>
</div>
<div className="relative mt-1 max-w-sm rounded-md shadow-sm">
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<GlobeAltIcon className="h-5 w-5 text-gray-400" aria-hidden="true" />
</div>
<input
type="text"
name="website"
className="block w-full rounded-md border-gray-300 pl-10 text-gray-300 sm:text-sm"
defaultValue={this.data.placeholder}
/>
</div>
<input
type="text"
id="help-text"
defaultValue={this.data.help}
className="mt-2 block w-full max-w-sm border-0 border-transparent p-0 text-sm font-light text-gray-500 ring-0 placeholder:text-gray-300 focus:ring-0"
placeholder="optional help text"
/>
</div>
);
ReactDOM.render(toolView, this.wrapper);
return this.wrapper;
}
}

View File

@@ -1,4 +1,4 @@
import { DocumentSearchIcon } from "@heroicons/react/outline";
import { DocumentMagnifyingGlassIcon } from "@heroicons/react/24/outline";
import Link from "next/link";
import { FaReact, FaVuejs } from "react-icons/fa";
import { toast } from "react-toastify";
@@ -36,7 +36,7 @@ export default function FormCode({ formId }) {
name: "Docs",
href: "https://docs.snoopforms.com",
bgColor: "bg-ui-gray-dark",
icon: DocumentSearchIcon,
icon: DocumentMagnifyingGlassIcon,
target: "_blank",
},
];
@@ -44,26 +44,24 @@ export default function FormCode({ formId }) {
return (
<>
<div className="mx-auto mt-8">
<h1 className="text-3xl font-bold leading-tight text-ui-gray-dark">
Connect your form
</h1>
<h1 className="text-ui-gray-dark text-3xl font-bold leading-tight">Connect your form</h1>
</div>
<div className="mt-4 mb-12">
<p className="text-ui-gray-dark">
To send all form submissions to this dashboard, update the form ID in
the <code>{"<snoopForm>"}</code> component.
To send all form submissions to this dashboard, update the form ID in the{" "}
<code>{"<snoopForm>"}</code> component.
</p>
</div>
<div className="grid grid-cols-2 gap-10">
<div>
<label htmlFor="formId" className="block text-base text-ui-gray-dark">
<label htmlFor="formId" className="text-ui-gray-dark block text-base">
Your form ID
</label>
<div className="mt-3">
<input
id="formId"
type="text"
className="w-full mb-3 border-gray-300 rounded-sm shadow-sm text-md disabled:bg-gray-100"
className="text-md mb-3 w-full rounded-sm border-gray-300 shadow-sm disabled:bg-gray-100"
value={formId}
disabled
/>
@@ -73,13 +71,12 @@ export default function FormCode({ formId }) {
navigator.clipboard.writeText(formId);
toast("Copied form ID to clipboard");
}}
fullwidth
>
fullwidth>
copy
</StandardButton>
</div>
</div>
<div className="p-8 font-light text-gray-200 bg-black rounded-md">
<div className="rounded-md bg-black p-8 font-light text-gray-200">
<p>
<code>
{"<"}
@@ -91,13 +88,10 @@ export default function FormCode({ formId }) {
<code>{`domain="${window?.location.host}"`}</code>
</p>
<p>
<code>{`protocol="${window?.location.protocol.replace(
":",
""
)}"`}</code>
<code>{`protocol="${window?.location.protocol.replace(":", "")}"`}</code>
</p>
<p>
<code>{`formId=${formId}`}</code>
<code>{`formId="${formId}"`}</code>
</p>
<p>
<code>{">"}</code>
@@ -115,73 +109,58 @@ export default function FormCode({ formId }) {
</div>
</div>
<div className="mt-16">
<h2 className="text-xl font-bold text-ui-gray-dark">Code your form</h2>
<h2 className="text-ui-gray-dark text-xl font-bold">Code your form</h2>
<div className="mt-4 mb-12">
<p className="text-ui-gray-dark">
Build your form with the code library of your choice. Manage your
data in this dashboard.
Build your form with the code library of your choice. Manage your data in this dashboard.
</p>
</div>
<ul
role="list"
className="grid grid-cols-1 gap-5 mt-3 sm:gap-6 sm:grid-cols-2"
>
<ul role="list" className="mt-3 grid grid-cols-1 gap-5 sm:grid-cols-2 sm:gap-6">
{libs.map((lib) => (
<a
className="flex col-span-1 rounded-md shadow-sm"
key={lib.id}
href={lib.href}
target={lib.target || ""}
rel="noreferrer"
>
<li
className={classNames(
lib.comingSoon
? "text-ui-gray-medium"
: "shadow-sm text-ui-gray-dark hover:text-black",
"flex col-span-1 rounded-md w-full"
)}
>
<div
<Link key={lib.id} href={lib.href}>
<a className="col-span-1 flex rounded-md shadow-sm" target={lib.target || ""} rel="noreferrer">
<li
className={classNames(
lib.bgColor,
"flex-shrink-0 flex items-center justify-center w-20 text-white text-sm font-medium rounded-l-md"
)}
>
<lib.icon
lib.comingSoon ? "text-ui-gray-medium" : "text-ui-gray-dark shadow-sm hover:text-black",
"col-span-1 flex w-full rounded-md"
)}>
<div
className={classNames(
lib.comingSoon
? "text-ui-gray-medium"
: "text-white stroke-1",
"w-10 h-10"
)}
/>
</div>
<div
className={classNames(
lib.comingSoon ? "border-dashed" : "",
"flex items-center justify-between flex-1 truncate bg-white rounded-r-md"
)}
>
<div className="inline-flex px-4 py-6 text-lg truncate">
<p className="font-light">{lib.name}</p>
{lib.comingSoon && (
<div className="p-1 px-3 ml-3 bg-green-100 rounded">
<p className="text-xs text-black">coming soon</p>
</div>
)}
lib.bgColor,
"flex w-20 flex-shrink-0 items-center justify-center rounded-l-md text-sm font-medium text-white"
)}>
<lib.icon
className={classNames(
lib.comingSoon ? "text-ui-gray-medium" : "stroke-1 text-white",
"h-10 w-10"
)}
/>
</div>
</div>
</li>
</a>
<div
className={classNames(
lib.comingSoon ? "border-dashed" : "",
"flex flex-1 items-center justify-between truncate rounded-r-md bg-white"
)}>
<div className="inline-flex truncate px-4 py-6 text-lg">
<p className="font-light">{lib.name}</p>
{lib.comingSoon && (
<div className="ml-3 rounded bg-green-100 p-1 px-3">
<p className="text-xs text-black">coming soon</p>
</div>
)}
</div>
</div>
</li>
</a>
</Link>
))}
</ul>
<div className="my-12 font-light text-center text-ui-gray-medium">
<div className="text-ui-gray-medium my-12 text-center font-light">
<p>
Your form is running? Go to{" "}
<Link href={`/forms/${formId}/preview`}>
<a className="underline text-red">Pipelines</a>
<a className="text-red underline">Pipelines</a>
</Link>
</p>
</div>

View File

@@ -1,6 +1,6 @@
/* This example requires Tailwind CSS v2.0+ */
import { Dialog, RadioGroup, Transition } from "@headlessui/react";
import { CheckCircleIcon, XIcon } from "@heroicons/react/solid";
import { CheckCircleIcon, XMarkIcon } from "@heroicons/react/24/solid";
import { useRouter } from "next/router";
import { Fragment, useState } from "react";
import { BsPlus } from "react-icons/bs";
@@ -13,14 +13,12 @@ const formTypes = [
{
id: "NOCODE",
title: "No-Code Builder",
description:
"Use the Notion-like builder to build your form without a single line of code.",
description: "Use the Notion-like builder to build your form without a single line of code.",
},
{
id: "CODE",
title: "Code",
description:
"Use the snoopReact library to code the form yourself and manage the data here.",
description: "Use the snoopReact library to code the form yourself and manage the data here.",
additionalDescription: "",
},
];
@@ -30,10 +28,7 @@ type FormOnboardingModalProps = {
setOpen: (v: boolean) => void;
};
export default function NewFormModal({
open,
setOpen,
}: FormOnboardingModalProps) {
export default function NewFormModal({ open, setOpen }: FormOnboardingModalProps) {
const router = useRouter();
const [name, setName] = useState("");
const [formType, setFormType] = useState(formTypes[0]);
@@ -60,13 +55,12 @@ export default function NewFormModal({
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 transition-opacity bg-gray-500 bg-opacity-30 backdrop-blur-md" />
leaveTo="opacity-0">
<div className="fixed inset-0 bg-gray-500 bg-opacity-30 backdrop-blur-md transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto">
<div className="flex items-end justify-center min-h-full p-4 text-center sm:items-center sm:p-0">
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
@@ -74,40 +68,32 @@ export default function NewFormModal({
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative px-4 pt-5 pb-4 text-left transition-all transform bg-white rounded-lg shadow-xl sm:my-8 sm:max-w-lg sm:w-full sm:p-6">
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95">
<Dialog.Panel className="relative transform rounded-lg bg-white px-4 pt-5 pb-4 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:p-6">
<div className="absolute top-0 right-0 hidden pt-4 pr-4 sm:block">
<button
type="button"
className="text-gray-400 bg-white rounded-md hover:text-gray-500 focus:outline-none focus:ring-0 focus:ring-offset-2"
onClick={() => setOpen(false)}
>
className="rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-0 focus:ring-offset-2"
onClick={() => setOpen(false)}>
<span className="sr-only">Close</span>
<XIcon className="w-6 h-6" aria-hidden="true" />
<XMarkIcon className="h-6 w-6" aria-hidden="true" />
</button>
</div>
<div className="flex flex-row justify-between">
<h2 className="flex-none p-2 text-xl font-bold text-ui-gray-dark">
Create new form
</h2>
<h2 className="text-ui-gray-dark flex-none p-2 text-xl font-bold">Create new form</h2>
</div>
<form
onSubmit={(e) => createFormAction(e)}
className="inline-block w-full p-2 overflow-hidden text-left align-bottom transition-all transform sm:align-middle"
>
className="inline-block w-full transform overflow-hidden p-2 text-left align-bottom transition-all sm:align-middle">
<div>
<label
htmlFor="email"
className="text-sm font-light text-ui-gray-dark"
>
<label htmlFor="email" className="text-ui-gray-dark text-sm font-light">
Name your form
</label>
<div className="mt-2">
<input
type="text"
name="name"
className="block w-full p-2 mb-6 border-none rounded bg-ui-gray-light focus:ring-2 focus:ring-red sm:text-sm placeholder:font-extralight placeholder:text-ui-gray-medium"
className="bg-ui-gray-light focus:ring-red placeholder:text-ui-gray-medium mb-6 block w-full rounded border-none p-2 placeholder:font-extralight focus:ring-2 sm:text-sm"
placeholder="e.g. Customer Research Survey"
value={name}
onChange={(e) => setName(e.target.value)}
@@ -118,11 +104,11 @@ export default function NewFormModal({
</div>
<RadioGroup value={formType} onChange={setFormType}>
<RadioGroup.Label className="text-sm font-light text-ui-gray-dark">
<RadioGroup.Label className="text-ui-gray-dark text-sm font-light">
How do you build your form?
</RadioGroup.Label>
<div className="grid grid-cols-1 mt-4 gap-y-6 sm:grid-cols-2 sm:gap-x-4">
<div className="mt-4 grid grid-cols-1 gap-y-6 sm:grid-cols-2 sm:gap-x-4">
{formTypes.map((formType) => (
<RadioGroup.Option
key={formType.id}
@@ -130,42 +116,34 @@ export default function NewFormModal({
className={({ checked, active }) =>
classNames(
checked ? "border-transparent" : "",
active
? "border-red ring-2 ring-red"
: "bg-ui-gray-lighter",
"relative bg-white border rounded shadow-sm p-4 flex cursor-pointer focus:outline-none"
active ? "border-red ring-red ring-2" : "bg-ui-gray-lighter",
"relative flex cursor-pointer rounded border bg-white p-4 shadow-sm focus:outline-none"
)
}
>
}>
{({ checked, active }) => (
<>
<span className="flex flex-1">
<span className="flex flex-col">
<RadioGroup.Label
as="span"
className="block font-bold text-md text-ui-gray-dark"
>
className="text-md text-ui-gray-dark block font-bold">
{formType.title}
</RadioGroup.Label>
<RadioGroup.Description
as="span"
className="flex items-center mt-1 text-xs whitespace-pre-wrap text-ui-gray-dark"
>
className="text-ui-gray-dark mt-1 flex items-center whitespace-pre-wrap text-xs">
{formType.description}
</RadioGroup.Description>
</span>
</span>
<CheckCircleIcon
className={classNames(
!checked ? "hidden" : "",
"h-5 w-5 text-red"
)}
className={classNames(!checked ? "hidden" : "", "text-red h-5 w-5")}
aria-hidden="true"
/>
<div
className={classNames(
checked ? "hidden" : "",
"h-4 w-4 rounded-full border-2 border-ui-gray-light"
"border-ui-gray-light h-4 w-4 rounded-full border-2"
)}
aria-hidden="true"
/>
@@ -173,7 +151,7 @@ export default function NewFormModal({
className={classNames(
active ? "border" : "border-2",
checked ? "border-red" : "border-transparent",
"absolute -inset-px rounded pointer-events-none"
"pointer-events-none absolute -inset-px rounded"
)}
aria-hidden="true"
/>
@@ -186,7 +164,7 @@ export default function NewFormModal({
<div className="mt-5 sm:mt-6">
<StandardButton fullwidth type="submit">
create form
<BsPlus className="w-6 h-6 ml-1"></BsPlus>
<BsPlus className="ml-1 h-6 w-6"></BsPlus>
</StandardButton>
</div>
</form>

View File

@@ -0,0 +1,184 @@
import { GlobeAltIcon, EnvelopeIcon, PhoneIcon } from "@heroicons/react/24/solid";
import sanitizeHtml from "sanitize-html";
import { SnoopElement, SnoopForm, SnoopPage } from "@snoopforms/react";
import { useMemo } from "react";
import { generateId } from "../../lib/utils";
import Loading from "../Loading";
export default function App({ id = "", formId, blocks, localOnly = false }) {
const pages = useMemo(() => {
const pages = [];
let currentPage = {
id: formId, // give the first page the formId as id by default
blocks: [],
};
for (const block of blocks) {
if (block.type !== "pageTransition") {
currentPage.blocks.push(block);
} else {
currentPage.blocks.push({
id: generateId(10),
data: {
label: block.data.submitLabel,
},
type: "submitButton",
});
pages.push(currentPage);
currentPage = {
id: block.id,
blocks: [],
};
}
}
pages.push(currentPage);
return pages;
}, [blocks, formId]);
if (!pages) return <Loading />;
return (
<div className="w-full px-5 py-5">
<SnoopForm
key={id} // used to reset form
domain={window.location.host}
protocol={window.location.protocol === "http:" ? "http" : "https"}
formId={formId}
localOnly={localOnly}
className="mx-auto w-full max-w-3xl space-y-6">
{pages.map((page, pageIdx) => (
<SnoopPage key={page.id} name={page.id} thankyou={pageIdx === pages.length - 1}>
{page.blocks.map((block) => (
<div key={block.id}>
{block.type === "paragraph" ? (
<div
className="ce-paragraph"
dangerouslySetInnerHTML={{
__html: sanitizeHtml(block.data.text),
}}></div>
) : block.type === "header" ? (
block.data.level === 1 ? (
<h1 className="ce-header">{block.data.text}</h1>
) : block.level === 2 ? (
<h2 className="ce-header">{block.data.text}</h2>
) : block.data.level === 3 ? (
<h3 className="ce-header">{block.data.text}</h3>
) : null
) : block.type === "textQuestion" ? (
<SnoopElement
type="text"
name={block.id}
label={block.data.label}
help={block.data.help}
placeholder={block.data.placeholder}
classNames={{
label: "mt-4 mb-2 block text-lg font-bold leading-7 text-gray-800 sm:truncate",
}}
required={block.data.required}
/>
) : block.type === "textareaQuestion" ? (
<SnoopElement
type="textarea"
rows={4}
name={block.id}
label={block.data.label}
help={block.data.help}
placeholder={block.data.placeholder}
classNames={{
label: "mt-4 mb-2 block text-lg font-bold leading-7 text-gray-800 sm:truncate",
}}
required={block.data.required}
/>
) : block.type === "emailQuestion" ? (
<SnoopElement
type="email"
name={block.id}
label={block.data.label}
help={block.data.help}
placeholder={block.data.placeholder}
icon={<EnvelopeIcon className="h-5 w-5" />}
classNames={{
label: "mt-4 mb-2 block text-lg font-bold leading-7 text-gray-800 sm:truncate",
}}
required={block.data.required}
/>
) : block.type === "multipleChoiceQuestion" && block.data.multipleChoice ? (
<SnoopElement
type="checkbox"
name={block.id}
label={block.data.label}
help={block.data.help}
options={block.data.options.map((o) => o.label)}
classNames={{
label: "mt-4 mb-2 block text-lg font-bold leading-7 text-gray-800 sm:truncate",
}}
required={block.data.required}
/>
) : block.type === "multipleChoiceQuestion" && !block.data.multipleChoice ? (
<SnoopElement
type="radio"
name={block.id}
label={block.data.label}
help={block.data.help}
options={block.data.options.map((o) => o.label)}
classNames={{
label: "mt-4 mb-2 block text-lg font-bold leading-7 text-gray-800 sm:truncate",
}}
required={block.data.required}
/>
) : block.type === "numberQuestion" ? (
<SnoopElement
type="number"
name={block.id}
label={block.data.label}
help={block.data.help}
placeholder={block.data.placeholder}
classNames={{
label: "mt-4 mb-2 block text-lg font-bold leading-7 text-gray-800 sm:truncate",
}}
required={block.data.required}
/>
) : block.type === "phoneQuestion" ? (
<SnoopElement
type="phone"
name={block.id}
label={block.data.label}
help={block.data.help}
placeholder={block.data.placeholder}
icon={<PhoneIcon className="h-5 w-5" />}
classNames={{
label: "mt-4 mb-2 block text-lg font-bold leading-7 text-gray-800 sm:truncate",
}}
required={block.data.required}
/>
) : block.type === "submitButton" ? (
<SnoopElement
name="submit"
type="submit"
label={block.data.label}
classNames={{
button:
"inline-flex items-center px-4 py-3 text-sm font-medium text-white bg-gray-700 border border-transparent rounded-md shadow-sm hover:bg-gray-900 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500",
}}
/>
) : block.type === "websiteQuestion" ? (
<SnoopElement
type="website"
name={block.id}
label={block.data.label}
help={block.data.help}
placeholder={block.data.placeholder}
icon={<GlobeAltIcon className="h-5 w-5" />}
classNames={{
label: "mt-4 mb-2 block text-lg font-bold leading-7 text-gray-800 sm:truncate",
}}
required={block.data.required}
/>
) : null}
</div>
))}
</SnoopPage>
))}
</SnoopForm>
</div>
);
}

View File

@@ -0,0 +1,63 @@
import Head from "next/head";
import { classNames } from "../../lib/utils";
import MenuBreadcrumbs from "./MenuBreadcrumbs";
import MenuProfile from "./MenuProfile";
import MenuSteps from "./MenuSteps";
import NewFormNavButton from "./NewFormNavButton";
interface BaseLayoutManagementProps {
title: string;
breadcrumbs: any;
steps?: any;
currentStep?: string;
children: React.ReactNode;
bgClass?: string;
limitHeightScreen?: boolean;
}
export default function BaseLayoutManagement({
title,
breadcrumbs,
steps,
currentStep,
children,
bgClass = "bg-ui-gray-lighter",
limitHeightScreen = false,
}: BaseLayoutManagementProps) {
return (
<>
<Head>
<title>{title}</title>
</Head>
<div
className={classNames(
bgClass,
limitHeightScreen ? "h-screen max-h-screen overflow-hidden" : "min-h-screen",
"flex h-full"
)}>
<div
className={classNames(limitHeightScreen ? "max-h-full" : "h-full", "flex w-full flex-1 flex-col")}>
<header className="w-full">
<div className="border-ui-gray-light relative z-10 flex h-16 flex-shrink-0 border-b bg-white shadow-sm">
<div className="grid w-full grid-cols-2 sm:grid-cols-3">
<div className="hidden flex-1 space-x-8 sm:flex">
<NewFormNavButton />
<MenuBreadcrumbs breadcrumbs={breadcrumbs} />
</div>
<div className="flex flex-1">
{steps && <MenuSteps steps={steps} currentStep={currentStep} />}
</div>
<div className="flex flex-1 items-center justify-end space-x-2 text-right sm:space-x-4">
<div className="mr-6">
<MenuProfile />
</div>
</div>
</div>
</div>
</header>
{children}
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,12 @@
import Head from "next/head";
export default function BaseLayoutUnauthorized({ title, children }) {
return (
<>
<Head>
<title>{title}</title>
</Head>
{children}
</>
);
}

View File

@@ -23,15 +23,11 @@ const EmptyPageFiller: React.FC<Props> = ({
return (
<div
className={
`bg-white border border-ui-gray-light text-center p-8 mx-auto mt-8 rounded-lg ` +
borderStyles
}
>
`border-ui-gray-light mx-auto mt-8 rounded-lg border bg-white p-8 text-center ` + borderStyles
}>
{children}
<h3 className="mt-5 text-base font-bold text-ui-gray-medium">
{alertText}
</h3>
<p className="mt-1 text-xs font-light text-ui-gray-medium">{hintText}</p>
<h3 className="text-ui-gray-medium mt-5 text-base font-bold">{alertText}</h3>
<p className="text-ui-gray-medium mt-1 text-xs font-light">{hintText}</p>
{hasButton && (
<div className="mt-6">
<StandardButton onClick={onClick}>{buttonText}</StandardButton>

View File

@@ -3,7 +3,7 @@ interface Props {
}
const FullWidth: React.FC<Props> = ({ children }) => {
return <main className="w-full h-full">{children}</main>;
return <main className="h-full w-full">{children}</main>;
};
export default FullWidth;

View File

@@ -1,7 +1,7 @@
import Head from "next/head";
import Link from "next/link";
import { ArrowLeftIcon, RefreshIcon } from "@heroicons/react/outline";
import { ArrowLeftIcon, ArrowPathIcon } from "@heroicons/react/24/outline";
import { useSession, signIn } from "next-auth/react";
import Loading from "../Loading";
@@ -23,31 +23,25 @@ export default function LayoutShare({ formId, resetApp, children }) {
<title>Form Preview</title>
</Head>
<div className="flex min-h-screen overflow-hidden bg-gray-50">
<div className="flex flex-col flex-1 overflow-hidden">
<div className="flex flex-1 flex-col overflow-hidden">
<header className="w-full">
<div className="relative z-10 flex flex-shrink-0 h-16 bg-white border-b border-ui-gray-light shadow-sm">
<div className="border-ui-gray-light relative z-10 flex h-16 flex-shrink-0 border-b bg-white shadow-sm">
<div className="flex flex-1 px-4 sm:px-6">
<div className="flex items-center flex-1">
<div className="flex flex-1 items-center">
<Link href={`/forms/${formId}/form`}>
<a>
<ArrowLeftIcon className="w-6 h-6" aria-hidden="true" />
<ArrowLeftIcon className="h-6 w-6" aria-hidden="true" />
</a>
</Link>
</div>
<p className="flex items-center justify-center flex-1 text-gray-600">
Preview
</p>
<div className="flex items-center justify-end flex-1 space-x-2 text-right sm:ml-6 sm:space-x-4">
<p className="flex flex-1 items-center justify-center text-gray-600">Preview</p>
<div className="flex flex-1 items-center justify-end space-x-2 text-right sm:ml-6 sm:space-x-4">
<button
type="button"
onClick={() => resetApp()}
className="inline-flex items-center px-4 py-2 text-sm font-medium text-white border border-transparent rounded-md shadow-sm bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"
>
className="inline-flex items-center rounded-md border border-transparent bg-red-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2">
Restart
<RefreshIcon
className="w-5 h-5 ml-2 -mr-1"
aria-hidden="true"
/>
<ArrowPathIcon className="ml-2 -mr-1 h-5 w-5" aria-hidden="true" />
</button>
</div>
</div>

View File

@@ -3,7 +3,7 @@ interface Props {
}
const LimitedWidth: React.FC<Props> = ({ children }) => {
return <main className="w-full h-full max-w-5xl mx-auto">{children}</main>;
return <main className="mx-auto h-full w-full max-w-5xl">{children}</main>;
};
export default LimitedWidth;

View File

@@ -1,19 +1,16 @@
import { HomeIcon } from "@heroicons/react/outline";
import { HomeIcon } from "@heroicons/react/24/outline";
import Link from "next/link";
export default function MenuBreadcrumbs({ breadcrumbs }) {
return (
<div className="hidden sm:flex sm:flex-1">
<div className="hidden overflow-hidden text-ellipsis sm:flex sm:flex-1">
<nav className="hidden lg:flex" aria-label="Breadcrumb">
<ol className="flex items-center space-x-4">
<li>
<div>
<Link href="/forms/">
<a className="text-ui-gray-dark hover:text-ui-gray-dark">
<HomeIcon
className="flex-shrink-0 w-5 h-5"
aria-hidden="true"
/>
<HomeIcon className="h-5 w-5 flex-shrink-0" aria-hidden="true" />
<span className="sr-only">Home</span>
</a>
</Link>
@@ -23,18 +20,16 @@ export default function MenuBreadcrumbs({ breadcrumbs }) {
<li key={crumb.name}>
<div className="flex items-center">
<svg
className="flex-shrink-0 w-5 h-5 text-ui-gray-medium"
className="text-ui-gray-medium h-5 w-5 flex-shrink-0"
xmlns="http://www.w3.org/2000/svg"
fill="currentColor"
viewBox="0 0 20 20"
aria-hidden="true"
>
aria-hidden="true">
<path d="M5.555 17.776l8-16 .894.448-8 16-.894-.448z" />
</svg>
<a
href={crumb.href}
className="ml-4 text-sm font-medium text-ui-gray-dark hover:text-ui-gray-dark"
>
className="text-ui-gray-dark hover:text-ui-gray-dark ml-4 truncate text-sm font-medium">
{crumb.name}
</a>
</div>

View File

@@ -1,4 +1,5 @@
import { Menu, Transition } from "@headlessui/react";
import { ArrowLeftOnRectangleIcon } from "@heroicons/react/24/solid";
import { signOut } from "next-auth/react";
import Image from "next/image";
import { Fragment } from "react";
@@ -10,9 +11,9 @@ export default function MenuProfile({}) {
{({ open }) => (
<>
<div className="inline-flex items-center ">
<Menu.Button className="flex ml-3 text-sm bg-white rounded-full focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500">
<Menu.Button className="ml-3 flex rounded-full bg-white text-sm focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2">
<span className="sr-only">Open user menu</span>
<div className="w-8 h-8">
<div className="h-8 w-8">
<Image
className="rounded-full"
src="/img/avatar-placeholder.png"
@@ -31,21 +32,19 @@ export default function MenuProfile({}) {
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
leaveTo="transform opacity-0 scale-95">
<Menu.Items
static
className="absolute right-0 w-48 py-1 mt-2 origin-top-right bg-white rounded-md shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"
>
className="absolute right-0 mt-2 w-48 origin-top-right rounded-sm bg-white p-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
<Menu.Item>
{({ active }) => (
<button
onClick={() => signOut()}
onClick={() => signOut({ callbackUrl: "/" })}
className={classNames(
active ? "bg-gray-100" : "",
"block px-4 py-2 text-sm text-ui-gray-dark w-full text-left"
)}
>
active ? "bg-ui-gray-light text-ui-black rounded-sm" : "text-ui-gray-dark",
"flex w-full px-4 py-2 text-sm"
)}>
<ArrowLeftOnRectangleIcon className="text-ui-gray-dark mr-3 h-5 w-5" aria-hidden="true" />
Sign Out
</button>
)}

View File

@@ -16,7 +16,7 @@ type MenuStepsProps = {
export default function MenuSteps({ steps, currentStep }: MenuStepsProps) {
const router = useRouter();
return (
<div className="flex items-center flex-1 justify-left sm:justify-center">
<div className="justify-left flex flex-1 items-center sm:justify-center">
<div className="w-full sm:hidden">
<label htmlFor="steps" className="sr-only">
Select a view
@@ -24,13 +24,12 @@ export default function MenuSteps({ steps, currentStep }: MenuStepsProps) {
<select
id="steps"
name="steps"
className="block w-full py-2 pl-3 pr-10 text-base rounded-md border-ui-gray-medium focus:outline-none focus:ring-red focus:border-red sm:text-sm"
className="border-ui-gray-medium focus:ring-red focus:border-red block w-full rounded-md py-2 pl-3 pr-10 text-base focus:outline-none sm:text-sm"
defaultValue={steps.find((step) => step.id === currentStep).name}
onChange={(e) => {
const stepId = e.target.children[e.target.selectedIndex].id;
router.push(steps.find((s) => s.id === stepId).href);
}}
>
}}>
{steps.map((step) => (
<option key={step.name} id={step.id}>
{step.name}
@@ -39,18 +38,17 @@ export default function MenuSteps({ steps, currentStep }: MenuStepsProps) {
</select>
</div>
<div className="hidden sm:block">
<nav className="flex -mb-px space-x-8" aria-label="steps">
<nav className="-mb-px flex space-x-8" aria-label="steps">
{steps.map((step) => (
<Link key={step.name} href={step.href}>
<a
className={classNames(
step.id === currentStep
? "border-red text-red"
: "border-transparent text-ui-gray-dark hover:text-ui-gray-dark hover:border-ui-gray-medium",
"whitespace-nowrap py-5 px-1 border-b-2 font-medium text-sm"
: "text-ui-gray-dark hover:text-ui-gray-dark hover:border-ui-gray-medium border-transparent",
"whitespace-nowrap border-b-2 py-5 px-1 text-sm font-medium"
)}
aria-current={step.id === currentStep ? "page" : undefined}
>
aria-current={step.id === currentStep ? "page" : undefined}>
{step.name}
</a>
</Link>

View File

@@ -1,4 +1,4 @@
import { PlusIcon } from "@heroicons/react/outline";
import { PlusIcon } from "@heroicons/react/24/outline";
import { useState } from "react";
import NewFormModal from "../form/NewFormModal";
@@ -8,15 +8,14 @@ export default function NewFormNavButton({}) {
<>
<button
type="button"
className="items-center hidden text-sm border-r border-ui-gray-light sm:flex bg-ui-gray-lighter text-ui-gray-dark hover:text-white hover:bg-red-500"
onClick={() => setOpenNewFormModal(true)}
>
<nav className="hidden lg:flex" aria-label="Breadcrumb">
className="border-ui-gray-light bg-ui-gray-lighter text-ui-gray-dark hidden items-center border-r text-sm hover:bg-red-500 hover:text-white sm:flex"
onClick={() => setOpenNewFormModal(true)}>
<nav className="hidden sm:flex" aria-label="Breadcrumb">
<ol className="flex items-center space-x-4">
<li>
<div className="inline-flex items-center px-6 py-2 text-sm font-medium leading-4 bg-transparent border border-transparent hover:text-white focus:outline-none">
<div className="inline-flex items-center border border-transparent bg-transparent px-6 py-2 text-sm font-medium leading-4 hover:text-white focus:outline-none">
<PlusIcon className="-ml-0.5 mr-2 h-4 w-4" aria-hidden="true" />
Start New Form
create form
</div>
</li>
</ol>

View File

@@ -0,0 +1,46 @@
import React from "react";
import { classNames } from "../../lib/utils";
interface NavItem {
id: string;
onClick: () => void;
Icon?: React.ElementType;
label?: string;
disabled?: boolean;
}
interface Props {
navItems: NavItem[];
currentItemId?: string;
}
// button component, consuming props
const SecondNavBar: React.FC<Props> = ({ navItems, currentItemId }) => {
return (
<div className="border-ui-gray-light bg-ui-gray-lighter flex flex-shrink-0 items-center justify-center border-b">
<nav className="flex space-x-10" aria-label="resultModes">
{navItems.map((navItem) => (
<button
key={navItem.id}
className={classNames(
`h-16 border-b-2 border-transparent text-xs`,
!navItem.disabled &&
(navItem.id === currentItemId
? "text-red border-red border-b-2"
: "text-ui-gray-dark hover:text-red bg-transparent hover:border-gray-300"),
navItem.disabled
? "text-ui-gray-medium"
: "hover:border-red text-ui-gray-dark hover:text-red hover:border-b-2"
)}
onClick={navItem.onClick}
disabled={navItem.disabled}>
{navItem.Icon && <navItem.Icon className="mx-auto mb-1 h-6 w-6 stroke-1" />}
{navItem.label}
</button>
))}
</nav>
</div>
);
};
export default SecondNavBar;

View File

@@ -0,0 +1,21 @@
import { signIn, useSession } from "next-auth/react";
import Loading from "../Loading";
const withAuthentication = (Component) =>
function WithAuth(props) {
const { status } = useSession({
required: true,
onUnauthenticated() {
// The user is not authenticated, handle it here.
return signIn();
},
});
if (status === "loading") {
return <Loading />;
}
return <Component {...props} />;
};
export default withAuthentication;

View File

@@ -0,0 +1,99 @@
/* This example requires Tailwind CSS v2.0+ */
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import { createPipeline, usePipelines } from "../../lib/pipelines";
import Modal from "../Modal";
import { webhook, WebhookSettings } from "./webhook";
const availablePipelines = [webhook];
const getEmptyPipeline = () => {
return { name: "", type: null, events: [], data: {} };
};
export default function AddPipelineModal({ open, setOpen }) {
const router = useRouter();
const formId = router.query.id.toString();
const [typeId, setTypeId] = useState(null);
const [pipeline, setPipeline] = useState(getEmptyPipeline());
const { pipelines, mutatePipelines } = usePipelines(formId);
useEffect(() => {
if (typeId !== pipeline.type) {
setPipeline({ ...pipeline, type: typeId });
}
}, [typeId, pipeline]);
useEffect(() => {
if (!open) {
setPipeline(getEmptyPipeline());
setTypeId(null);
}
}, [open]);
const handleSubmit = async (e) => {
e.preventDefault();
const newPipeline = await createPipeline(router.query.id, pipeline);
const newPipelines = JSON.parse(JSON.stringify(pipelines));
newPipelines.push(newPipeline);
mutatePipelines(newPipelines);
setOpen(false);
};
return (
<Modal open={open} setOpen={setOpen}>
<>
{typeId === null ? (
<>
<h2 className="text-ui-gray-dark mb-6 text-xl font-bold">
Please choose a pipeline you want to add
</h2>
{availablePipelines.map((pipeline) => (
<div
className="border-ui-gray-light w-full border bg-white shadow sm:rounded"
key={pipeline.title}>
<div className="px-4 py-5 sm:p-6">
<h3 className="text-lg font-medium leading-6 text-gray-900">{pipeline.title}</h3>
<div className="mt-2 sm:flex sm:items-start sm:justify-between">
<div className="max-w-xl text-sm text-gray-500">
<p>{pipeline.description}</p>
</div>
<div className="mt-5 sm:mt-0 sm:ml-6 sm:flex sm:flex-shrink-0 sm:items-center">
<button
type="button"
onClick={() => {
setTypeId(pipeline.typeId);
}}
className="inline-flex items-center rounded-md border border-transparent bg-red-600 px-4 py-2 font-medium text-white shadow-sm hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 sm:text-sm">
Select
</button>
</div>
</div>
</div>
</div>
))}
</>
) : (
<form className="w-full space-y-8 divide-y divide-gray-200" onSubmit={handleSubmit}>
{typeId === "WEBHOOK" ? <WebhookSettings pipeline={pipeline} setPipeline={setPipeline} /> : null}
<div className="pt-5">
<div className="flex justify-end">
<button
type="button"
onClick={() => setOpen(false)}
className="rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2">
Cancel
</button>
<button
type="submit"
className="ml-3 inline-flex justify-center rounded-md border border-transparent bg-red-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2">
Create
</button>
</div>
</div>
</form>
)}
</>
</Modal>
);
}

View File

@@ -0,0 +1,50 @@
import { persistPipeline, usePipeline, usePipelines } from "../../lib/pipelines";
import Loading from "../Loading";
import Modal from "../Modal";
import { WebhookSettings } from "./webhook";
export default function UpdatePipelineModal({ open, setOpen, formId, pipelineId }) {
const { pipeline, isLoadingPipeline, mutatePipeline } = usePipeline(formId, pipelineId);
const { pipelines, mutatePipelines } = usePipelines(formId);
const handleSubmit = async (e) => {
e.preventDefault();
await persistPipeline(pipeline);
const newPipelines = JSON.parse(JSON.stringify(pipelines));
const pipelineIdx = pipelines.findIndex((p) => p.id === pipelineId);
if (pipelineIdx > -1) {
newPipelines[pipelineIdx] = pipeline;
mutatePipelines(newPipelines);
}
setOpen(false);
};
return (
<Modal open={open} setOpen={setOpen}>
{isLoadingPipeline ? (
<Loading />
) : (
<form className="w-full space-y-8 divide-y divide-gray-200" onSubmit={handleSubmit}>
{pipeline.type === "WEBHOOK" ? (
<WebhookSettings pipeline={pipeline} setPipeline={(p) => mutatePipeline(p, false)} />
) : null}
<div className="pt-5">
<div className="flex justify-end">
<button
type="button"
onClick={() => setOpen(false)}
className="rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2">
Cancel
</button>
<button
type="submit"
className="ml-3 inline-flex justify-center rounded-md border border-transparent bg-red-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2">
Update
</button>
</div>
</div>
</form>
)}
</Modal>
);
}

View File

@@ -0,0 +1,157 @@
const eventTypes = [
{
id: "PAGE_SUBMISSION",
name: "Page Submission",
description: "every time a form page is submitted (partial submission)",
},
];
export function WebhookSettings({ pipeline, setPipeline }) {
const toggleEvent = (eventId) => {
const newPipeline = JSON.parse(JSON.stringify(pipeline));
const eventIdx = newPipeline.events.indexOf(eventId);
if (eventIdx !== -1) {
newPipeline.events.splice(eventIdx, 1);
} else {
newPipeline.events.push(eventId);
}
setPipeline(newPipeline);
};
const updateField = (field, value, parent = null) => {
const newPipeline = JSON.parse(JSON.stringify(pipeline));
if (parent) {
newPipeline[parent][field] = value;
} else {
newPipeline[field] = value;
}
setPipeline(newPipeline);
};
return (
<div className="space-y-8 divide-y divide-gray-200">
<div>
<h2 className="text-ui-gray-dark mb-3 text-xl font-bold">Configure Webhook</h2>
<p className="mt-1 text-sm text-gray-500">
Configure your webhook. To learn more about how webhooks work, please check out our docs.
</p>
<div className="mt-6 grid grid-cols-1 gap-y-6 gap-x-4 sm:grid-cols-6">
<div className="sm:col-span-4">
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
Webhook Name
</label>
<div className="mt-1">
<input
type="text"
name="name"
id="name"
value={pipeline.name || ""}
onChange={(e) => updateField("name", e.target.value)}
className="block w-full rounded-md border-gray-300 shadow-sm focus:border-red-500 focus:ring-red-500 sm:text-sm"
required
/>
</div>
</div>
<div className="sm:col-span-4">
<label htmlFor="endpointUrl" className="block text-sm font-medium text-gray-700">
Endpoint URL
</label>
<div className="mt-1">
<input
type="url"
pattern="^https:\/\/(.*)"
onInvalid={(e: any) =>
e.target.setCustomValidity("please provide a valid website address with https")
}
onInput={(e: any) => e.target.setCustomValidity("")}
name="endpointUrl"
id="endpointUrl"
value={pipeline.data.endpointUrl || ""}
onChange={(e) => updateField("endpointUrl", e.target.value, "data")}
className="block w-full rounded-md border-gray-300 shadow-sm focus:border-red-500 focus:ring-red-500 sm:text-sm"
required
/>
</div>
<p className="mt-2 text-xs text-gray-500" id="email-description">
Your server URL to which the data should be sent (https required)
</p>
</div>
<div className="sm:col-span-4">
<label htmlFor="secret" className="block text-sm font-medium text-gray-700">
Secret
</label>
<div className="mt-1">
<input
type="text"
name="secret"
id="secret"
value={pipeline.data.secret || ""}
onChange={(e) => updateField("secret", e.target.value, "data")}
className="block w-full rounded-md border-gray-300 shadow-sm focus:border-red-500 focus:ring-red-500 sm:text-sm"
/>
</div>
<p className="mt-2 text-xs text-gray-500" id="email-description">
We sign all event notification payloads with a SHA256 signature using this secret
</p>
</div>
</div>
</div>
<div className="pt-8">
<div>
<h3 className="text-lg font-medium leading-6 text-gray-900">Advanced Settings</h3>
<p className="mt-1 text-sm text-gray-500">Set up this webhook to fit your needs.</p>
</div>
<div className="mt-6">
<fieldset>
<legend className="sr-only">Events</legend>
<div className="text-base font-medium text-gray-900" aria-hidden="true">
Events
</div>
<div className="mt-4 space-y-4">
{eventTypes.map((eventType) => (
<div key={eventType.id}>
<div className="relative flex items-start">
<div className="flex h-5 items-center">
<input
id="comments"
name="comments"
type="checkbox"
checked={pipeline.events.includes(eventType.id)}
onChange={() => toggleEvent(eventType.id)}
className="h-4 w-4 rounded-sm border-gray-300 text-red-600 focus:ring-red-500"
/>
</div>
<div className="ml-3 text-sm">
<label htmlFor="comments" className="font-medium text-gray-700">
{eventType.name}
</label>
<p className="text-gray-500">{eventType.description}</p>
</div>
</div>
</div>
))}
</div>
</fieldset>
</div>
<div className="mt-6">
<fieldset>
<legend className="sr-only">Conditions</legend>
<div className="text-base font-medium text-gray-900" aria-hidden="true">
Conditions
</div>
<div className="mt-4 space-y-4">
<div className="rounded-sm border border-gray-100 bg-gray-50 px-2 py-5">
<p className="flex justify-center text-xs text-gray-600">
conditional data piping coming soon
</p>
</div>
</div>
</fieldset>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,22 @@
import crypto from "crypto";
import { ApiEvent } from "../../../lib/types";
export async function handleWebhook(pipeline, event: ApiEvent) {
if (pipeline.data.hasOwnProperty("endpointUrl") && pipeline.data.hasOwnProperty("secret")) {
if (event.type === "pageSubmission" && pipeline.events.includes("PAGE_SUBMISSION")) {
const webhookData = pipeline.data;
const body = { time: Math.floor(Date.now() / 1000), event };
fetch(webhookData.endpointUrl.toString(), {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Hub-Signature-256": `sha256=${crypto
.createHmac("sha256", webhookData.secret.toString())
.update(JSON.stringify(body))
.digest("base64")}`,
},
body: JSON.stringify(body),
});
}
}
}

View File

@@ -0,0 +1,8 @@
export const webhook = {
typeId: "WEBHOOK",
title: "Webhook",
description: "Notify an external endpoint when events happen in your form (e.g. a new submission).",
};
export * from "./SettingsComponent";
export * from "./handler";

View File

@@ -0,0 +1,65 @@
import { QuestionMarkCircleIcon } from "@heroicons/react/24/outline";
import { ArrowDownIcon, ArrowUpIcon } from "@heroicons/react/24/solid";
import React from "react";
import { classNames } from "../../lib/utils";
interface Props {
value: string | number;
label: string;
toolTipText: string;
trend?: number;
smallerText?: boolean;
}
const AnalyticsCard: React.FC<Props> = ({ value, label, toolTipText, trend, smallerText }) => {
return (
<div className="rounded-md bg-white shadow-md">
<div key={label} className="px-4 py-5 sm:p-6">
<dt className="has-tooltip inline-flex text-base font-normal text-gray-900">
{label}{" "}
{toolTipText && (
<QuestionMarkCircleIcon className="text-red hover:text-ui-gray-dark ml-1 h-4 w-4" />
)}
{toolTipText && (
<span className="tooltip -mt-6 -ml-8 flex grow rounded bg-gray-600 p-1 px-4 text-center text-xs text-white shadow-lg">
{toolTipText}
</span>
)}
</dt>
<dd className="mt-1 flex items-baseline justify-between md:block lg:flex">
<div
className={classNames(
smallerText ? "text-lg" : "text-xl",
"flex items-baseline text-xl font-semibold text-gray-800"
)}>
{value}
</div>
{trend && (
<div
className={classNames(
trend >= 0 ? "bg-green-100 text-green-800" : "bg-red-100 text-red-800",
"inline-flex items-baseline rounded-full px-2.5 py-0.5 text-sm font-medium md:mt-2 lg:mt-0"
)}>
{trend >= 0 ? (
<ArrowUpIcon
className="-ml-1 mr-0.5 h-5 w-5 flex-shrink-0 self-center text-green-500"
aria-hidden="true"
/>
) : (
<ArrowDownIcon
className="-ml-1 mr-0.5 h-5 w-5 flex-shrink-0 self-center text-red-500"
aria-hidden="true"
/>
)}
<span className="sr-only">{trend >= 0 ? "Increased" : "Decreased"} by</span>
{trend} %
</div>
)}
</dd>
</div>
</div>
);
};
export default AnalyticsCard;

View File

@@ -1,26 +1,20 @@
import { Menu, Transition } from "@headlessui/react";
import { ChevronDownIcon } from "@heroicons/react/solid";
import { ChevronDownIcon } from "@heroicons/react/24/solid";
import { parseAsync } from "json2csv";
import { Fragment } from "react";
import { useForm } from "../../lib/forms";
import {
getSubmission,
useSubmissionSessions,
} from "../../lib/submissionSessions";
import { getSubmission, useSubmissionSessions } from "../../lib/submissionSessions";
import { Submission } from "../../lib/types";
import { slugify } from "../../lib/utils";
import Loading from "../Loading";
export default function DownloadResponses({ formId }) {
const { submissionSessions, isLoadingSubmissionSessions } =
useSubmissionSessions(formId);
const { submissionSessions, isLoadingSubmissionSessions } = useSubmissionSessions(formId);
const { form, isLoadingForm } = useForm(formId);
const download = async (format: "csv" | "excel") => {
// build dict of answers in copy of answerSessions
const submissions: Submission[] = submissionSessions.map((s) =>
getSubmission(s, form.schema)
);
const submissions: Submission[] = submissionSessions.map((s) => getSubmission(s, form.schema));
// build data fields for csv/excel file
const data = [];
for (const submission of submissions) {
@@ -72,10 +66,7 @@ export default function DownloadResponses({ formId }) {
const url = window.URL.createObjectURL(new Blob([blob]));
const link = document.createElement("a");
link.href = url;
link.setAttribute(
"download",
`${slugify(form.name)}.${fileTypes[format].fileExtension}`
);
link.setAttribute("download", `${slugify(form.name)}.${fileTypes[format].fileExtension}`);
document.body.appendChild(link);
link.click();
link.parentNode.removeChild(link);
@@ -91,12 +82,9 @@ export default function DownloadResponses({ formId }) {
return (
<Menu as="div" className="relative z-10 inline-block w-full text-left">
<div>
<Menu.Button className="inline-flex justify-center w-full px-4 py-2 text-sm font-medium text-white bg-gray-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-opacity-75">
<Menu.Button className="inline-flex w-full justify-center bg-gray-400 px-4 py-2 text-sm font-medium text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-opacity-75">
Download
<ChevronDownIcon
className="w-5 h-5 ml-2 -mr-1 text-white hover:text-gray-100"
aria-hidden="true"
/>
<ChevronDownIcon className="ml-2 -mr-1 h-5 w-5 text-white hover:text-gray-100" aria-hidden="true" />
</Menu.Button>
</div>
<Transition
@@ -106,9 +94,8 @@ export default function DownloadResponses({ formId }) {
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items className="absolute right-0 w-56 mt-2 origin-top-right bg-white divide-y divide-gray-100 rounded-md shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
leaveTo="transform opacity-0 scale-95">
<Menu.Items className="absolute right-0 mt-2 w-56 origin-top-right divide-y divide-gray-100 rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
<div className="px-1 py-1 ">
<Menu.Item>
{({ active }) => (
@@ -116,8 +103,7 @@ export default function DownloadResponses({ formId }) {
onClick={() => download("csv")}
className={`${
active ? "bg-red-500 text-white" : "text-gray-900"
} group flex rounded-md items-center w-full px-2 py-2 text-sm`}
>
} group flex w-full items-center rounded-md px-2 py-2 text-sm`}>
Download as CSV
</button>
)}

View File

@@ -0,0 +1,87 @@
import Image from "next/image";
import { useMemo } from "react";
import { getSubmissionAnalytics, useSubmissionSessions } from "../../lib/submissionSessions";
import { timeSince } from "../../lib/utils";
import Loading from "../Loading";
import AnalyticsCard from "./AnalyticsCard";
export default function ResultsAnalytics({ formId }) {
const { submissionSessions, isLoadingSubmissionSessions } = useSubmissionSessions(formId);
const analytics = useMemo(() => {
if (!isLoadingSubmissionSessions) {
return getSubmissionAnalytics(submissionSessions);
}
}, [isLoadingSubmissionSessions, submissionSessions]);
const stats = useMemo(() => {
if (analytics) {
return [
{
id: "totalSubmissions",
name: "Total Submissions",
stat: analytics.totalSubmissions || "--",
trend: undefined,
toolTipText: undefined,
},
{
id: "lastSubmission",
name: "Last Submission",
stat: analytics.lastSubmissionAt ? timeSince(analytics.lastSubmissionAt) : "--",
smallerText: true,
toolTipText: undefined,
},
];
}
}, [analytics]);
if (!stats || !analytics) {
return <Loading />;
}
return (
<div className="my-8">
<h2 className="text-ui-gray-dark text-xl font-bold">Analytics</h2>
<div>
<dl className="mt-8 grid grid-cols-1 gap-5 sm:grid-cols-2">
{stats.map((item) => (
<AnalyticsCard
key={item.id}
value={item.stat}
label={item.name}
toolTipText={item.toolTipText}
trend={item.trend}
smallerText={item.smallerText}
/>
))}
</dl>
</div>
<div className="flex items-end">
<h2 className="text-ui-gray-dark mt-16 text-xl font-bold">Optimize Form</h2>
<div className="ml-2 rounded-sm bg-green-50 px-3 py-2 text-xs text-green-800">
<p>coming soon</p>
</div>
</div>
<div className="mt-8 grid grid-cols-2 gap-10">
<div className="rounded-md bg-white p-5 shadow-md">
<Image
src="/../../img/drop-offs-v1.svg"
alt="drop-off"
layout="responsive"
width={500}
height={273}
/>
</div>
<div className="rounded-md bg-white p-5 shadow-md">
<Image
src="/../../img/a-b-test-v1.svg"
alt="drop-off"
layout="responsive"
width={500}
height={273}
/>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,191 @@
import { useEffect, useState } from "react";
import { RadioGroup } from "@headlessui/react";
import { CheckIcon } from "@heroicons/react/24/solid";
import { getEventName } from "../../lib/events";
import { useSubmissionSessions } from "../../lib/submissionSessions";
import { SubmissionSession } from "../../lib/types";
import { convertDateTimeString, convertTimeString } from "../../lib/utils";
import SubmissionDisplay from "./SubmissionDisplay";
import DownloadResponses from "./DownloadResponses";
import Loading from "../Loading";
import { TrashIcon } from "@heroicons/react/24/outline";
import { toast } from "react-toastify";
function classNames(...classes) {
return classes.filter(Boolean).join(" ");
}
type ResultsResponseProps = {
formId: string;
};
export default function ResultsResponses({ formId }: ResultsResponseProps) {
const { submissionSessions, isLoadingSubmissionSessions, mutateSubmissionSessions } =
useSubmissionSessions(formId);
const [activeSubmissionSession, setActiveSubmissionSession] = useState<SubmissionSession | null>(null);
const handleDelete = async (submissionSession: SubmissionSession) => {
try {
await fetch(`/api/forms/${formId}/submissionSessions/${submissionSession.id}`, {
method: "DELETE",
});
await mutateSubmissionSessions();
setActiveSubmissionSession(null);
toast("Successfully deleted");
} catch (error) {
toast(error);
}
};
useEffect(() => {
if (!isLoadingSubmissionSessions && submissionSessions.length > 0) {
setActiveSubmissionSession(submissionSessions[0]);
}
}, [isLoadingSubmissionSessions, submissionSessions]);
if (isLoadingSubmissionSessions) {
return <Loading />;
}
return (
<div className="max-w-screen mx-auto flex h-full w-full flex-1 flex-col overflow-visible">
<div className="relative z-0 flex h-full flex-1 overflow-visible">
<main className="relative z-0 mb-32 flex-1 overflow-y-auto focus:outline-none xl:order-last">
<div className="overflow-visible sm:rounded-lg">
{!activeSubmissionSession ? (
<button
type="button"
className="relative mx-auto mt-8 block w-96 rounded-lg border-2 border-dashed border-gray-300 p-12 text-center hover:border-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2">
<span className="mt-2 block text-sm font-medium text-gray-500">
Select a response on the left to see the details here
</span>
</button>
) : (
<>
<div className="bg-white px-4 py-5 shadow sm:px-12 sm:pb-4 sm:pt-12">
<div className="grid grid-cols-2 gap-8 divide-x">
<div className="flow-root">
<h1 className="mb-8 text-gray-700">
{convertDateTimeString(activeSubmissionSession.createdAt)}
</h1>
<SubmissionDisplay
key={activeSubmissionSession.id}
submissionSession={activeSubmissionSession}
formId={formId}
/>
</div>
<div className="hidden pl-10 md:flow-root">
<h1 className="mb-8 text-gray-700">Session Activity</h1>
<ul role="list" className="-mb-8">
{activeSubmissionSession.events.map((event, eventIdx) => (
<li key={event.id}>
<div className="relative pb-8">
{eventIdx !== activeSubmissionSession.events.length - 1 ? (
<span
className="bg-ui-gray-light absolute top-4 left-4 -ml-px h-full w-0.5"
aria-hidden="true"
/>
) : null}
<div className="relative flex space-x-3">
<div>
<span
className={classNames(
"bg-red-200",
"flex h-8 w-8 items-center justify-center rounded-full ring-8 ring-white"
)}>
<CheckIcon className="h-5 w-5 text-white" aria-hidden="true" />
</span>
</div>
<div className="flex min-w-0 flex-1 flex-wrap justify-between gap-4 pt-1.5">
<div>
<p className="text-sm text-gray-500">
{getEventName(event.type)}
{/* <span className="font-medium text-gray-900">
{event.data.pageName || ""}
</span> */}
</p>
</div>
<div className="whitespace-nowrap text-right text-sm text-gray-500">
<time dateTime={event.createdAt}>
{convertTimeString(event.createdAt)}
</time>
</div>
</div>
</div>
</div>
</li>
))}
</ul>
</div>
</div>
</div>
<div className="w-full">
<button
className="flex w-full items-center justify-center gap-2 border border-transparent bg-gray-300 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-red-500 focus:outline-none"
onClick={() => {
if (
confirm("Are you sure you want to delete this submission? It will be gone forever!")
) {
handleDelete(activeSubmissionSession);
}
}}>
<TrashIcon className="h-4 w-4" />
Delete Submission
</button>
</div>
</>
)}
</div>
</main>
<aside className="border-ui-gray-light order-first flex h-full flex-1 flex-shrink-0 flex-col border-r md:w-96 md:flex-none">
<DownloadResponses formId={formId} />
<div className="pt-4 pb-2">
<h2 className="px-5 text-lg font-medium text-gray-900">Responses</h2>
</div>
{submissionSessions.length === 0 ? (
<p className="mt-3 px-5 text-sm text-gray-500">No responses yet</p>
) : (
<RadioGroup
value={activeSubmissionSession}
onChange={setActiveSubmissionSession}
className="mb-32 min-h-0 flex-1 overflow-y-auto shadow-inner"
as="div">
<div className="relative">
<ul className="divide-ui-gray-light relative z-0 divide-y">
{submissionSessions.map((submissionSession) => (
<RadioGroup.Option
key={submissionSession.id}
value={submissionSession}
className={({ checked }) =>
classNames(
checked ? "bg-gray-100" : "",
"relative flex items-center space-x-3 px-6 py-5 "
)
}>
<div className="min-w-0 flex-1">
<button
onClick={() => setActiveSubmissionSession(submissionSession)}
className="w-full text-left focus:outline-none">
{/* Extend touch target to entire panel */}
<span className="absolute inset-0" aria-hidden="true" />
<p className="text-sm font-medium text-gray-900">
{convertDateTimeString(submissionSession.createdAt)}
</p>
<p className="truncate text-sm text-gray-500">
{submissionSession.events.length} events
</p>
</button>
</div>
</RadioGroup.Option>
))}
</ul>
</div>
</RadioGroup>
)}
</aside>
</div>
</div>
);
}

View File

@@ -10,14 +10,14 @@ import { timeSince } from "../../lib/utils";
import AnalyticsCard from "./AnalyticsCard";
import Loading from "../Loading";
import TextResults from "./summary/TextResults";
import ChoiceResults from "./summary/ChoiceResults";
export default function ResultsSummary({ formId }) {
const { submissionSessions, isLoadingSubmissionSessions } =
useSubmissionSessions(formId);
const { submissionSessions, isLoadingSubmissionSessions } = useSubmissionSessions(formId);
const { form, isLoadingForm } = useForm(formId);
const analytics = useMemo(() => {
const insights = useMemo(() => {
if (!isLoadingSubmissionSessions) {
return getSubmissionAnalytics(submissionSessions);
}
@@ -30,41 +30,34 @@ export default function ResultsSummary({ formId }) {
}, [isLoadingSubmissionSessions, submissionSessions, isLoadingForm, form]);
const stats = useMemo(() => {
if (analytics) {
if (insights) {
return [
{
id: "uniqueUsers",
name: "Unique Users",
stat: analytics.uniqueUsers || "--",
toolTipText: "Tracked without cookies using fingerprinting technique",
trend: undefined,
},
{
id: "totalSubmissions",
name: "Total Submissions",
stat: analytics.totalSubmissions || "--",
stat: insights.totalSubmissions || "--",
trend: undefined,
toolTipText: undefined,
},
{
id: "lastSubmission",
name: "Last Submission",
stat: timeSince(analytics.lastSubmissionAt) || "--",
stat: insights.lastSubmissionAt ? timeSince(insights.lastSubmissionAt) : "--",
smallerText: true,
toolTipText: undefined,
},
];
}
}, [analytics]);
}, [insights]);
if (!summary || !analytics) {
if (!summary || !insights) {
return <Loading />;
}
return (
<>
<h2 className="mt-8 text-xl font-bold text-ui-gray-dark">
Responses Overview
</h2>
<dl className="grid grid-cols-1 gap-5 mt-8 sm:grid-cols-2 lg:grid-cols-3">
<h2 className="text-ui-gray-dark mt-8 text-xl font-bold">Responses Overview</h2>
<dl className="mt-8 grid grid-cols-1 gap-5 sm:grid-cols-2">
{stats.map((item) => (
<AnalyticsCard
key={item.id}
@@ -83,8 +76,10 @@ export default function ResultsSummary({ formId }) {
page.type === "form" && (
<div key={page.name}>
{page.elements.map((element) =>
element.type === "text" || element.type === "textarea" ? (
["email", "number", "phone", "text", "textarea", "website"].includes(element.type) ? (
<TextResults element={element} />
) : ["checkbox", "radio"].includes(element.type) ? (
<ChoiceResults element={element} />
) : null
)}
</div>

View File

@@ -44,22 +44,18 @@ export default function Submission({ formId, submissionSession }) {
}
return (
<div className="bg-white shadow sm:rounded-lg max-w-">
<div className="max-w- bg-white shadow sm:rounded-lg">
<div className="px-4 py-5 sm:p-6">
<div className="text-ui-gray-dark-600">
<p className="text-sm">
{convertDateTimeString(submission.createdAt)}
</p>
<p className="text-sm">{convertDateTimeString(submission.createdAt)}</p>
{submission.pages.map((page) => (
<div key={page.name}>
{page.elements?.map(
(element) =>
element.type !== "submit" && (
<div key={element.name}>
<p className="font-semibold text-red">{element.label}</p>
<p className="font-normal">
{element.value || "[not provided]"}
</p>
<p className="text-red font-semibold">{element.label}</p>
<p className="font-normal">{element.value || "[not provided]"}</p>
</div>
)
)}

View File

@@ -19,23 +19,20 @@ export default function SubmissionDisplay({ formId, submissionSession }) {
}
return (
<div className="flow-root mt-6">
<ul role="list" className="-my-5 divide-y divide-ui-gray-light">
<div className="flow-root">
<ul role="list" className="divide-ui-gray-light divide-y">
{submission.pages.map((page) =>
page.elements?.map(
(element) =>
element.type !== "submit" && (
<li key={element.name} className="py-5">
<p className="text-sm font-semibold text-gray-800">
{element.label}
</p>
<p className="text-sm font-semibold text-gray-800">{element.label}</p>
<p
className={classNames(
element.value ? "text-gray-600" : "text-gray-400",
"mt-1 text-sm text-gray-600 line-clamp-2"
)}
>
"whitespace-pre-line pt-1 text-sm text-gray-600"
)}>
{element.value || "[not provided]"}
</p>
</li>

View File

@@ -0,0 +1,70 @@
import {
AtSymbolIcon,
CheckCircleIcon,
GlobeAltIcon,
HashtagIcon,
Bars4Icon,
PhoneIcon,
} from "@heroicons/react/24/outline";
import { IoMdRadioButtonOn } from "react-icons/io";
import { classNames } from "../../../lib/utils";
export const elementTypes = [
{
type: "email",
icon: AtSymbolIcon,
},
{
type: "number",
icon: HashtagIcon,
},
{
type: "phone",
icon: PhoneIcon,
},
{
type: "text",
icon: Bars4Icon,
},
{
type: "textarea",
icon: Bars4Icon,
},
{
type: "checkbox",
icon: CheckCircleIcon,
},
{
type: "radio",
icon: IoMdRadioButtonOn,
},
{
type: "website",
icon: GlobeAltIcon,
},
];
export const getElementTypeIcon = (type) => {
const elementType = elementTypes.find((e) => e.type === type);
return elementType ? (
<span className={classNames(`text-white`, `bg-red-500`, "inline-flex rounded-lg p-3 ring-4 ring-white")}>
<elementType.icon className="h-4 w-4" aria-hidden="true" />
</span>
) : null;
};
export default function BaseResults({ element, children }) {
return (
<div className="my-8 overflow-hidden rounded-lg bg-white shadow">
<div className="px-4 py-5 sm:p-6">
<div className="flex items-center">
<div className="flex-shrink-0">{getElementTypeIcon(element.type)}</div>
<div className="ml-4">
<h3 className="text-md font-medium leading-6 text-gray-900">{element.label}</h3>
</div>
</div>
</div>
{children}
</div>
);
}

View File

@@ -0,0 +1,47 @@
import "chart.js/auto";
import { Chart } from "react-chartjs-2";
import BaseResults from "./BaseResults";
export default function ChoiceResults({ element }) {
const data = {
//labels: element.data.options,
labels: element.options.map((o) => o.label),
datasets: [
{
//data: getDataset(element, elementAnswers),
data: element.options.map((o) => o.summary || 0),
backgroundColor: ["rgba(245, 59, 87, 0.7)"],
borderColor: ["rgba(245, 59, 87, 1)"],
borderWidth: 1,
},
],
};
const options: any = {
indexAxis: "y",
responsive: true,
plugins: {
legend: {
display: false,
},
},
scales: {
yAxis: [
{
ticks: {
min: 1,
precision: 0,
},
},
],
},
};
return (
<BaseResults element={element}>
<div className="my-4 mt-6 flow-root px-8 text-center">
<Chart type="bar" data={data} options={options} height={75} />
</div>
</BaseResults>
);
}

View File

@@ -3,10 +3,10 @@ import BaseResults from "./BaseResults";
export default function TextResults({ element }) {
return (
<BaseResults element={element}>
<div className="flow-root px-8 my-4 mt-6 overflow-y-scroll text-center max-h-64">
<ul className="-my-5 divide-y divide-ui-gray-light">
{element.summary.map((answer) => (
<li key={answer} className="py-8">
<div className="my-4 mt-6 flow-root h-44 max-h-64 overflow-y-scroll px-8 text-center">
<ul className="divide-ui-gray-light -my-5 divide-y">
{element?.summary?.map((answer) => (
<li key={answer} className="py-4">
<div className="relative focus-within:ring-2 focus-within:ring-indigo-500">
<h3 className="text-sm text-gray-700">
{/* Extend touch target to entire panel */}

View File

@@ -1,4 +1,4 @@
import { prisma } from "./prisma";
import { prisma } from "@formbricks/database";
export const formHasOwnership = async (session, formId) => {
try {

83
apps/web/lib/apiEvents.ts Normal file
View File

@@ -0,0 +1,83 @@
import { handleWebhook } from "../components/pipelines/webhook";
import { capturePosthogEvent } from "./posthog";
import { prisma } from "@formbricks/database";
import { sendTelemetry } from "./telemetry";
import { ApiEvent } from "./types";
type validationError = {
status: number;
message: string;
};
export const validateEvents = (events: ApiEvent[]): validationError | undefined => {
if (!Array.isArray(events)) {
return { status: 400, message: `"events" needs to be a list` };
}
for (const event of events) {
if (
!["createSubmissionSession", "pageSubmission", "submissionCompleted", "updateSchema"].includes(
event.type
)
) {
return {
status: 400,
message: `event type ${event.type} is not suppported`,
};
}
return;
}
};
export const processApiEvent = async (event: ApiEvent, formId) => {
// save submission
if (event.type === "pageSubmission") {
const data = event.data;
await prisma.sessionEvent.create({
data: {
type: "pageSubmission",
data: {
pageName: data.pageName,
submission: data.submission,
},
submissionSession: { connect: { id: data.submissionSessionId } },
},
});
const form = await prisma.form.findUnique({
where: {
id: formId,
},
});
capturePosthogEvent(form.ownerId, "pageSubmission received", {
formId,
formType: form.formType,
});
sendTelemetry("pageSubmission received");
} else if (event.type === "submissionCompleted") {
// TODO
} else if (event.type === "updateSchema") {
const data = { schema: event.data, updatedAt: new Date() };
await prisma.form.update({
where: { id: formId },
data,
});
} else {
throw Error(`apiEvents: unsupported event type in event ${JSON.stringify(event)}`);
}
// handle integrations
const pipelines = await prisma.pipeline.findMany({
where: {
form: { id: formId },
enabled: true,
},
orderBy: [
{
createdAt: "desc",
},
],
});
for (const pipeline of pipelines) {
if (pipeline.type === "WEBHOOK") {
handleWebhook(pipeline, event);
}
}
};

28
apps/web/lib/auth.ts Normal file
View File

@@ -0,0 +1,28 @@
import { compare, hash } from "bcryptjs";
export async function hashPassword(password: string) {
const hashedPassword = await hash(password, 12);
return hashedPassword;
}
export async function verifyPassword(password: string, hashedPassword: string) {
const isValid = await compare(password, hashedPassword);
return isValid;
}
export function requireAuthentication(gssp) {
return async (context) => {
const { req, resolvedUrl } = context;
const token = req.cookies.userToken;
if (!token) {
return {
redirect: {
destination: `/auth/signin?callbackUrl=${encodeURIComponent(resolvedUrl)}`,
statusCode: 302,
},
};
}
return await gssp(context); // Continue on to call `getServerSideProps` logic
};
}

77
apps/web/lib/email.ts Normal file
View File

@@ -0,0 +1,77 @@
import { createToken } from "./jwt";
const nodemailer = require("nodemailer");
interface sendEmailData {
to: string;
subject: string;
text?: string;
html: string;
}
export const sendEmail = async (emailData: sendEmailData) => {
let transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: process.env.SMTP_PORT,
secure: process.env.SMTP_SECURE_ENABLED === "1", // true for 465, false for other ports
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASSWORD,
},
// logger: true,
// debug: true,
});
const emailDefaults = {
from: `snoopForms <${process.env.MAIL_FROM || "noreply@snoopforms.com"}>`,
};
await transporter.sendMail({ ...emailDefaults, ...emailData });
};
export const sendVerificationEmail = async (user) => {
const token = createToken(user.id, user.email, {
expiresIn: "1d",
});
const verifyLink = `${process.env.NEXTAUTH_URL}/auth/verify?token=${encodeURIComponent(token)}`;
const verificationRequestLink = `${
process.env.NEXTAUTH_URL
}/auth/verification-requested?email=${encodeURIComponent(user.email)}`;
await sendEmail({
to: user.email,
subject: "Welcome to snoopForms",
html: `Welcome to snoopForms!<br/><br/>To verify your email address and start using snoopForms please click this link:<br/>
<a href="${verifyLink}">${verifyLink}</a><br/>
<br/>
The link is valid for one day. If it has expired please request a new token here:<br/>
<a href="${verificationRequestLink}">${verificationRequestLink}</a><br/>
<br/>
Your snoopForms Team`,
});
};
export const sendForgotPasswordEmail = async (user) => {
const token = createToken(user.id, user.email, {
expiresIn: "1d",
});
const verifyLink = `${process.env.NEXTAUTH_URL}/auth/reset-password?token=${encodeURIComponent(token)}`;
await sendEmail({
to: user.email,
subject: "Reset your snoopForms password",
html: `You have requested a link to change your password. You can do this through the link below:<br/>
<a href="${verifyLink}">${verifyLink}</a><br/>
<br/>
The link is valid for 24 hours. If you didn't request this, please ignore this email.<br/>
<br/>
Your password won't change until you access the link above and create a new one.<br/>
<br/>
Your snoopForms Team`,
});
};
export const sendPasswordResetNotifyEmail = async (user) => {
await sendEmail({
to: user.email,
subject: "Your snoopForms password has been changed",
html: `We're contacting you to notify you that your password has been changed.<br/>
<br/>
Your snoopForms Team`,
});
};

View File

@@ -12,7 +12,7 @@ export const useForms = () => {
};
};
export const useForm = (id) => {
export const useForm = (id: string) => {
const { data, error, mutate } = useSWR(`/api/forms/${id}/`, fetcher);
return {
@@ -56,15 +56,7 @@ export const getFormElementFieldSetter = (
elementId: string
) => {
return (input, field, parentField = "") =>
setFormElementField(
form,
mutateForm,
pageId,
elementId,
input,
field,
parentField
);
setFormElementField(form, mutateForm, pageId, elementId, input, field, parentField);
};
export const setFormElementField = (
@@ -77,17 +69,12 @@ export const setFormElementField = (
parentField: string = ""
) => {
const updatedForm = JSON.parse(JSON.stringify(form));
const elementIdx = getFormPage(updatedForm, pageId).elements.findIndex(
(e) => e.id === elementId
);
const elementIdx = getFormPage(updatedForm, pageId).elements.findIndex((e) => e.id === elementId);
if (typeof elementIdx === "undefined") {
throw Error(
`setFormElementField: unable to find element with id ${elementId}`
);
throw Error(`setFormElementField: unable to find element with id ${elementId}`);
}
if (parentField !== "") {
getFormPage(updatedForm, pageId).elements[elementIdx][parentField][field] =
input;
getFormPage(updatedForm, pageId).elements[elementIdx][parentField][field] = input;
} else {
getFormPage(updatedForm, pageId).elements[elementIdx][field] = input;
}

24
apps/web/lib/jwt.ts Normal file
View File

@@ -0,0 +1,24 @@
import jwt from "jsonwebtoken";
import { prisma } from "@formbricks/database";
export function createToken(userId, userEmail, options = {}) {
return jwt.sign({ id: userId }, process.env.NEXTAUTH_SECRET + userEmail, options);
}
export async function verifyToken(token, userEmail = "") {
if (!userEmail) {
const { id } = jwt.decode(token);
const foundUser = await prisma.user.findUnique({
where: { id },
});
if (!foundUser) {
return null;
}
userEmail = foundUser.email;
}
return jwt.verify(token, process.env.NEXTAUTH_SECRET + userEmail);
}

View File

@@ -1,4 +1,4 @@
import { DocumentSearchIcon, TerminalIcon } from "@heroicons/react/outline";
import { DocumentMagnifyingGlassIcon, CommandLineIcon } from "@heroicons/react/24/outline";
import { useRouter } from "next/router";
import { FaReact } from "react-icons/fa";
@@ -10,7 +10,7 @@ export const useCodeSecondNavigation = (formId) => {
onClick: () => {
router.push(`/forms/${formId}/form`);
},
Icon: TerminalIcon,
Icon: CommandLineIcon,
label: "Form ID",
},
{
@@ -31,7 +31,7 @@ export const useCodeSecondNavigation = (formId) => {
{
id: "vueJs",
onClick: () => {},
Icon: TerminalIcon,
Icon: CommandLineIcon,
label: "VueJs",
disabled: true,
},
@@ -40,7 +40,7 @@ export const useCodeSecondNavigation = (formId) => {
onClick: () => {
window.open("https://docs.snoopforms.com", "_ blank");
},
Icon: DocumentSearchIcon,
Icon: DocumentMagnifyingGlassIcon,
label: "Docs",
},
];

View File

@@ -1,8 +1,4 @@
import {
ChartBarIcon,
InboxIcon,
TrendingUpIcon,
} from "@heroicons/react/outline";
import { ChartBarIcon, InboxIcon, ArrowTrendingUpIcon } from "@heroicons/react/24/outline";
import { useRouter } from "next/router";
export const useFormResultsSecondNavigation = (formId) => {
@@ -25,12 +21,12 @@ export const useFormResultsSecondNavigation = (formId) => {
label: "Responses",
},
{
id: "analytics",
id: "insights",
onClick: () => {
router.push(`/forms/${formId}/results/analytics`);
router.push(`/forms/${formId}/results/insights`);
},
Icon: TrendingUpIcon,
label: "Analytics",
Icon: ArrowTrendingUpIcon,
label: "Insights",
},
];
};

View File

@@ -2,10 +2,7 @@ import useSWR from "swr";
import { fetcher } from "./utils";
export const useNoCodeForm = (formId) => {
const { data, error, mutate } = useSWR(
`/api/forms/${formId}/nocodeform`,
fetcher
);
const { data, error, mutate } = useSWR(`/api/forms/${formId}/nocodeform`, fetcher);
return {
noCodeForm: data,
@@ -16,10 +13,7 @@ export const useNoCodeForm = (formId) => {
};
export const useNoCodeFormPublic = (formId) => {
const { data, error, mutate } = useSWR(
`/api/public/forms/${formId}/nocodeform`,
fetcher
);
const { data, error, mutate } = useSWR(`/api/public/forms/${formId}/nocodeform`, fetcher);
return {
noCodeForm: data,
@@ -39,9 +33,7 @@ export const createNoCodeForm = async (formId) => {
return await res.json();
} catch (error) {
console.error(error);
throw Error(
`createNoCodeForm: unable to create noCodeForm: ${error.message}`
);
throw Error(`createNoCodeForm: unable to create noCodeForm: ${error.message}`);
}
};

62
apps/web/lib/pipelines.ts Normal file
View File

@@ -0,0 +1,62 @@
import useSWR from "swr";
import { fetcher } from "./utils";
export const usePipeline = (formId: string, pipelineId: string) => {
const { data, error, mutate } = useSWR(() => `/api/forms/${formId}/pipelines/${pipelineId}`, fetcher);
return {
pipeline: data,
isLoadingPipeline: !error && !data,
isErrorPipeline: error,
mutatePipeline: mutate,
};
};
export const usePipelines = (formId: string) => {
const { data, error, mutate } = useSWR(() => `/api/forms/${formId}/pipelines`, fetcher);
return {
pipelines: data,
isLoadingPipelines: !error && !data,
isErrorPipelines: error,
mutatePipelines: mutate,
};
};
export const persistPipeline = async (pipeline) => {
try {
await fetch(`/api/forms/${pipeline.formId}/pipelines/${pipeline.id}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(pipeline),
});
} catch (error) {
console.error(error);
}
};
export const createPipeline = async (formId, pipeline) => {
try {
const res = await fetch(`/api/forms/${formId}/pipelines`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(pipeline),
});
return await res.json();
} catch (error) {
console.error(error);
throw Error(`create Pipeline: unable to create pipeline: ${error.message}`);
}
};
export const deletePipeline = async (formId, pipelineId) => {
try {
const res = await fetch(`/api/forms/${formId}/pipelines/${pipelineId}`, {
method: "DELETE",
});
return await res.json();
} catch (error) {
console.error(error);
throw Error(`delete Pipeline: unable to delete pipeline: ${error.message}`);
}
};

27
apps/web/lib/posthog.ts Normal file
View File

@@ -0,0 +1,27 @@
import { hashString } from "./utils";
const enabled =
process.env.NODE_ENV === "production" && process.env.POSTHOG_API_HOST && process.env.POSTHOG_API_KEY;
export const capturePosthogEvent = async (userId, eventName, properties = {}) => {
if (!enabled) {
return;
}
try {
await fetch(`${process.env.POSTHOG_API_HOST}/capture/`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
api_key: process.env.POSTHOG_API_KEY,
event: eventName,
properties: {
distinct_id: hashString(userId.toString()),
...properties,
},
timestamp: new Date().toISOString(),
}),
});
} catch (error) {
console.error("error sending posthog event:", error);
}
};

View File

@@ -3,10 +3,7 @@ import { Schema, SubmissionSession, SubmissionSummary } from "./types";
import { fetcher } from "./utils";
export const useSubmissionSessions = (formId: string) => {
const { data, error, mutate } = useSWR(
() => `/api/forms/${formId}/submissionSessions`,
fetcher
);
const { data, error, mutate } = useSWR(() => `/api/forms/${formId}/submissionSessions`, fetcher);
return {
submissionSessions: data,
@@ -32,9 +29,7 @@ export const getSubmission = (submissionSession, schema) => {
const submissionPage = {
name: page.name,
type: page.type,
elements: page.elements
? JSON.parse(JSON.stringify(page.elements))
: [],
elements: page.elements ? JSON.parse(JSON.stringify(page.elements)) : [],
};
// search for elements in schema pages of type "form" and fill their value into the submission
if (page.type === "form") {
@@ -44,9 +39,8 @@ export const getSubmission = (submissionSession, schema) => {
if (typeof pageSubmission !== "undefined") {
for (const [elementIdx, element] of page.elements.entries()) {
if (element.type !== "submit") {
if (element.name in pageSubmission.data?.submission) {
submissionPage.elements[elementIdx].value =
pageSubmission.data.submission[element.name];
if ("submission" in pageSubmission.data && element.name in pageSubmission.data?.submission) {
submissionPage.elements[elementIdx].value = pageSubmission.data.submission[element.name];
}
}
}
@@ -58,73 +52,59 @@ export const getSubmission = (submissionSession, schema) => {
return submission;
};
export const getSubmissionAnalytics = (
submissionSessions: SubmissionSession[]
) => {
const uniqueUsers = [];
export const getSubmissionAnalytics = (submissionSessions: SubmissionSession[]) => {
let totalSubmissions = 0;
let lastSubmissionAt = null;
for (const submissionSession of submissionSessions) {
// collect unique users
if (!uniqueUsers.includes(submissionSession.userFingerprint)) {
uniqueUsers.push(submissionSession.userFingerprint);
}
if (submissionSession.events.length > 0) {
totalSubmissions += 1;
const lastSubmission =
submissionSession.events[submissionSession.events.length - 1];
const lastSubmission = submissionSession.events[submissionSession.events.length - 1];
if (!lastSubmissionAt) {
lastSubmissionAt = lastSubmission.createdAt;
} else if (
Date.parse(lastSubmission.createdAt) > Date.parse(lastSubmissionAt)
) {
} else if (Date.parse(lastSubmission.createdAt) > Date.parse(lastSubmissionAt)) {
lastSubmissionAt = lastSubmission.createdAt;
}
}
}
return {
lastSubmissionAt,
uniqueUsers: uniqueUsers.length,
totalSubmissions: totalSubmissions,
};
};
export const getSubmissionSummary = (
submissionSessions: SubmissionSession[],
schema: Schema
) => {
export const getSubmissionSummary = (submissionSessions: SubmissionSession[], schema: Schema) => {
if (!schema) return;
const summary: SubmissionSummary = JSON.parse(JSON.stringify(schema));
// iterate through SubmissionSessions and add values to summary
for (const submissionSession of submissionSessions) {
for (const submissionEvent of submissionSession.events) {
if (submissionEvent.type === "pageSubmission") {
const summaryPage = summary.pages.find(
(p) => p.name === submissionEvent.data.pageName
);
if (summaryPage.type === "form") {
for (const [elementName, elementValue] of Object.entries(
submissionEvent.data.submission
)) {
const elementInSummary = summaryPage.elements.find(
(e) => e.name === elementName
);
const summaryPage = summary.pages.find((p) => p.name === submissionEvent.data.pageName);
if (summaryPage.type === "form" && submissionEvent.data.submission) {
for (const [elementName, elementValue] of Object.entries(submissionEvent.data.submission)) {
const elementInSummary = summaryPage.elements.find((e) => e.name === elementName);
if (typeof elementInSummary !== "undefined") {
if (
elementInSummary.type === "text" ||
elementInSummary.type === "textarea"
["email", "number", "phone", "text", "textarea", "website"].includes(elementInSummary.type)
) {
if (!("summary" in elementInSummary)) {
elementInSummary.summary = [];
}
elementInSummary.summary.push(elementValue);
} else if (
elementInSummary.type === "radio" ||
elementInSummary.type === "checkbox"
) {
const optionInSummary = elementInSummary.options.find(
(o) => o.value === elementValue
);
} else if (elementInSummary.type === "checkbox") {
// checkbox values are a list of values
for (const value of elementValue) {
const optionInSummary = elementInSummary.options.find((o) => o.value === value);
if (typeof optionInSummary !== "undefined") {
if (!("summary" in optionInSummary)) {
optionInSummary.summary = 0;
}
optionInSummary.summary += 1;
}
}
} else if (elementInSummary.type === "radio") {
const optionInSummary = elementInSummary.options.find((o) => o.value === elementValue);
if (typeof optionInSummary !== "undefined") {
if (!("summary" in optionInSummary)) {
optionInSummary.summary = 0;

31
apps/web/lib/telemetry.ts Normal file
View File

@@ -0,0 +1,31 @@
import { hashString } from "./utils";
/* We use this telemetry service to better understand how snoopForms is being used
and how we can improve it. All data including the IP address is collected anonymously
and we cannot trace anything back to you or your customers. If you still want to
disable telemetry, set the environment variable TELEMETRY_DISABLED=1 */
export const sendTelemetry = async (eventName: string) => {
if (
process.env.TELEMETRY_DISABLED !== "1" &&
process.env.NODE_ENV === "production" &&
process.env.NEXTAUTH_URL !== "http://localhost:3000"
) {
try {
await fetch("https://posthog.snoopforms.com/capture/", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
api_key: "phc_BTq4eagaCzPyUSURXVYwlScTQRvcmBDXjYh7OG6kiqw",
event: eventName,
properties: {
distinct_id: hashString(process.env.NEXTAUTH_URL),
},
timestamp: new Date().toISOString(),
}),
});
} catch (error) {
console.log("error sending telemetry:", error);
}
}
};

View File

@@ -40,9 +40,20 @@ export type SchemaPage = {
elements: SchemaElement[];
};
export type SnoopType =
| "checkbox"
| "email"
| "number"
| "phone"
| "radio"
| "submit"
| "text"
| "textarea"
| "website";
export type SchemaElement = {
name: string;
type: "checkbox" | "radio" | "text" | "textarea" | "submit";
type: SnoopType;
label?: string;
options?: SchemaOption[];
};
@@ -64,7 +75,7 @@ export type SubmissionSummaryPage = {
export type SubmissionSummaryElement = {
name: string;
type: "checkbox" | "radio" | "text" | "textarea" | "submit";
type: SnoopType;
label?: string;
summary?: string[];
options?: SubmissionSummaryOption[];
@@ -104,10 +115,7 @@ export type updateSchemaEvent = {
data: Schema;
};
export type ApiEvent =
| pageSubmissionEvent
| submissionCompletedEvent
| updateSchemaEvent;
export type ApiEvent = pageSubmissionEvent | submissionCompletedEvent | updateSchemaEvent;
export type WebhookEvent = Event & { formId: string; timestamp: string };

83
apps/web/lib/users.ts Normal file
View File

@@ -0,0 +1,83 @@
import { hashPassword } from "./auth";
export const createUser = async (firstname, lastname, email, password) => {
const hashedPassword = await hashPassword(password);
try {
const res = await fetch(`/api/public/users`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
firstname,
lastname,
email,
password: hashedPassword,
}),
});
if (res.status !== 200) {
const json = await res.json();
throw Error(json.error);
}
return await res.json();
} catch (error) {
throw Error(`${error.message}`);
}
};
export const resendVerificationEmail = async (email) => {
try {
const res = await fetch(`/api/public/users/verification-email`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email,
}),
});
if (res.status !== 200) {
const json = await res.json();
throw Error(json.error);
}
return await res.json();
} catch (error) {
throw Error(`${error.message}`);
}
};
export const forgotPassword = async (email: string) => {
try {
const res = await fetch(`/api/public/users/forgot-password`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email,
}),
});
if (res.status !== 200) {
const json = await res.json();
throw Error(json.error);
}
return await res.json();
} catch (error) {
throw Error(`${error.message}`);
}
};
export const resetPassword = async (token, password) => {
const hashedPassword = await hashPassword(password);
try {
const res = await fetch(`/api/public/users/reset-password`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
token,
hashedPassword,
}),
});
if (res.status !== 200) {
const json = await res.json();
throw Error(json.error);
}
return await res.json();
} catch (error) {
throw Error(`${error.message}`);
}
};

View File

@@ -1,5 +1,6 @@
import intlFormat from "date-fns/intlFormat";
import { formatDistance } from "date-fns";
import crypto from "crypto";
export const fetcher = async (url) => {
const res = await fetch(url);
@@ -26,10 +27,7 @@ export const shuffle = (array) => {
randomIndex = Math.floor(Math.random() * currentIndex);
currentIndex--;
[array[currentIndex], array[randomIndex]] = [
array[randomIndex],
array[currentIndex],
];
[array[currentIndex], array[randomIndex]] = [array[randomIndex], array[currentIndex]];
}
return array;
@@ -116,3 +114,17 @@ export const timeSince = (dateString: string) => {
addSuffix: true,
});
};
export const generateId = (length) => {
let result = "";
const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
const charactersLength = characters.length;
for (let i = 0; i < length; i++) {
result += characters.charAt(Math.floor(Math.random() * charactersLength));
}
return result;
};
export const hashString = (string: string) => {
return crypto.createHash("sha256").update(string).digest("hex");
};

View File

@@ -3,6 +3,10 @@ var path = require("path");
const nextConfig = {
reactStrictMode: false,
output: "standalone",
experimental: {
outputFileTracingRoot: path.join(__dirname, "../../"),
},
async redirects() {
return [
{
@@ -10,6 +14,11 @@ const nextConfig = {
destination: "/forms/",
permanent: false,
},
{
source: "/forms/:id",
destination: "/forms/:id/form",
permanent: false,
},
];
},
webpack: (config, { buildId, dev, isServer, defaultLoaders, webpack }) => {

64
apps/web/package.json Normal file
View File

@@ -0,0 +1,64 @@
{
"name": "@formbricks/web",
"version": "1.0.1",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"overrides": {
"@types/react": "^18.0.21"
},
"dependencies": {
"@editorjs/editorjs": "^2.25.0",
"@editorjs/header": "^2.6.2",
"@editorjs/paragraph": "^2.8.0",
"@headlessui/react": "^1.7.3",
"@heroicons/react": "^2.0.12",
"@snoopforms/react": "workspace:*",
"bcryptjs": "^2.4.3",
"chart.js": "^3.9.1",
"crypto": "^1.0.1",
"date-fns": "^2.29.3",
"editorjs-drag-drop": "^1.1.7",
"editorjs-undo": "^2.0.9",
"highlight.js": "^11.6.0",
"json2csv": "^5.0.7",
"jsonwebtoken": "^8.5.1",
"next": "12.3.1",
"next-auth": "^4.14.0",
"nextjs-cors": "^2.1.1",
"nodemailer": "^6.8.0",
"react": "18.2.0",
"react-chartjs-2": "^4.3.1",
"react-dom": "18.2.0",
"react-icons": "^4.6.0",
"react-loader-spinner": "^5.3.4",
"react-toastify": "^9.0.8",
"sanitize-html": "^2.7.2",
"sharp": "^0.31.1",
"swr": "^1.3.0",
"@formbricks/ui": "workspace:*",
"uuid": "^9.0.0"
},
"devDependencies": {
"@tailwindcss/forms": "^0.5.3",
"@tailwindcss/line-clamp": "^0.4.2",
"@tailwindcss/typography": "^0.5.7",
"@types/bcryptjs": "^2.4.2",
"@types/node": "18.11.0",
"@types/react": "^18.0.21",
"autoprefixer": "^10.4.12",
"@formbricks/database": "workspace:*",
"eslint": "8.25.0",
"eslint-config-formbricks": "workspace:*",
"postcss": "^8.4.18",
"@formbricks/tailwind-config": "workspace:*",
"tailwindcss": "^3.1.8",
"ts-node": "^10.9.1",
"@formbricks/tsconfig": "workspace:*",
"typescript": "4.8.4"
}
}

30
apps/web/pages/404.tsx Normal file
View File

@@ -0,0 +1,30 @@
import Image from "next/image";
import BaseLayoutUnauthorized from "../components/layout/BaseLayoutUnauthorized";
import Link from "next/link";
export default function Error404Page() {
return (
<BaseLayoutUnauthorized title="Page not found">
<div className="bg-ui-gray-light flex min-h-screen">
<div className="mx-auto flex flex-1 flex-col justify-center px-4 py-12 sm:px-6 lg:flex-none lg:px-20 xl:px-24">
<div className="mx-auto w-full max-w-sm p-8 lg:w-96">
<div>
<Image src="/img/snoopforms-logo.svg" alt="snoopForms logo" width={500} height={89} />
</div>
<div className="mt-8">
<h1 className="leading-2 mb-4 text-center font-bold">This page does not exist!</h1>
<p className="text-center">
Sorry, the page you were looking for could not be found. Please make sure the URL is correct
or{" "}
<span className="underline">
<Link href="/">go back to the homepage</Link>
</span>
.
</p>
</div>
</div>
</div>
</div>
</BaseLayoutUnauthorized>
);
}

View File

@@ -1,14 +1,12 @@
import "../styles/globals.css";
import "../styles/editorjs.css";
import "../styles/toastify.css";
import type { AppProps } from "next/app";
import "highlight.js/styles/tokyo-night-dark.css";
import { SessionProvider } from "next-auth/react";
import { ToastContainer } from "react-toastify";
import "../styles/editorjs.css";
// import "@formbricks/ui/styles.css";
import "../styles/globals.css";
import "../styles/toastify.css";
export default function App({
Component,
pageProps: { session, ...pageProps },
}: AppProps) {
function SnoopApp({ Component, pageProps: { session, ...pageProps } }) {
return (
<SessionProvider session={session}>
<Component {...pageProps} />
@@ -16,3 +14,5 @@ export default function App({
</SessionProvider>
);
}
export default SnoopApp;

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